From 03ad49f215a9b8b7f796d74fd523e7e47f9d2908 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Mon, 26 Jan 2026 17:48:02 +0100 Subject: [PATCH 1/2] feat(plugin): add marketplace management commands Add subcommands to manage plugin marketplaces (registries) for discovering and installing plugins: - - Add a marketplace - - List configured marketplaces - - Remove a marketplace - - Update marketplace indexes Marketplaces are stored in ~/.openhands/marketplaces.json Implements #411 --- openhands_cli/argparsers/main_parser.py | 4 + openhands_cli/argparsers/plugin_parser.py | 209 ++++++++++++++++++ openhands_cli/entrypoint.py | 5 + openhands_cli/locations.py | 3 + openhands_cli/plugins/__init__.py | 1 + openhands_cli/plugins/marketplace_commands.py | 198 +++++++++++++++++ openhands_cli/plugins/marketplace_storage.py | 197 +++++++++++++++++ 7 files changed, 617 insertions(+) create mode 100644 openhands_cli/argparsers/plugin_parser.py create mode 100644 openhands_cli/plugins/__init__.py create mode 100644 openhands_cli/plugins/marketplace_commands.py create mode 100644 openhands_cli/plugins/marketplace_storage.py 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..9d53a5f4d --- /dev/null +++ b/openhands_cli/argparsers/plugin_parser.py @@ -0,0 +1,209 @@ +"""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 + openhands plugin marketplace add https://plugins.openhands.ai/index.json + + # Add a marketplace with a friendly name + openhands plugin marketplace add https://plugins.openhands.ai/index.json --name "Official" + + # List configured marketplaces + openhands plugin marketplace list + + # Remove a marketplace + openhands plugin marketplace remove https://plugins.openhands.ai/index.json + + # Update marketplace indexes + openhands plugin marketplace update + + # Update a specific marketplace + openhands plugin marketplace update https://plugins.openhands.ai/index.json +""" + 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 are URLs that provide an index of available plugins. +Configure marketplaces to discover and install plugins. + +Examples: + + # Add a marketplace + openhands plugin marketplace add https://plugins.openhands.ai/index.json + + # Add with a name + openhands plugin marketplace add https://plugins.openhands.ai/index.json --name "Official" + + # List all marketplaces + openhands plugin marketplace list + + # Remove a marketplace + openhands plugin marketplace remove https://plugins.openhands.ai/index.json + + # Update all marketplace indexes + openhands plugin marketplace update +""" + 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. + +Examples: + + # Add a marketplace + openhands plugin marketplace add https://plugins.openhands.ai/index.json + + # Add with a friendly name + openhands plugin marketplace add https://plugins.openhands.ai/index.json --name "Official" +""" + add_parser = marketplace_subparsers.add_parser( + "add", + help="Add a plugin marketplace", + description=add_description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + add_parser.add_argument( + "url", + help="URL of the marketplace index (e.g., https://plugins.example.com/index.json)", + ) + add_parser.add_argument( + "--name", + "-n", + help="Friendly name for the marketplace", + ) + + # marketplace remove command + remove_description = """ +Remove a plugin marketplace. + +Examples: + + # Remove a marketplace by URL + openhands plugin marketplace remove https://plugins.openhands.ai/index.json +""" + remove_parser = marketplace_subparsers.add_parser( + "remove", + help="Remove a plugin marketplace", + description=remove_description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + remove_parser.add_argument( + "url", + help="URL of the marketplace to remove", + ) + + # marketplace list command + list_description = """ +List all configured plugin marketplaces. + +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 https://plugins.openhands.ai/index.json +""" + update_parser = marketplace_subparsers.add_parser( + "update", + help="Update marketplace indexes", + description=update_description, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + update_parser.add_argument( + "url", + nargs="?", + help="URL 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..dae8561d0 --- /dev/null +++ b/openhands_cli/plugins/marketplace_commands.py @@ -0,0 +1,198 @@ +"""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(url=args.url, name=name) + console.print( + f"Successfully added marketplace: {args.url}", + style=OPENHANDS_THEME.success, + ) + if marketplace.name: + console.print(f" Name: {marketplace.name}", 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(url=args.url) + console.print( + f"Successfully removed marketplace: {args.url}", + 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("URL", style="cyan", no_wrap=True) + table.add_column("Name", style="green") + table.add_column("Added", style="dim") + table.add_column("Last Updated", style="dim") + + 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 "-" + + table.add_row( + m.url, + m.name or "-", + added, + updated, + ) + + console.print(table) + + 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 URL if specified + url_filter = getattr(args, "url", None) + if url_filter: + marketplaces = [m for m in marketplaces if m.url == url_filter] + if not marketplaces: + console.print( + f"Marketplace not found: {url_filter}", + style=OPENHANDS_THEME.error, + ) + raise SystemExit(1) + + # Update each marketplace + for marketplace in marketplaces: + console.print( + f"Updating marketplace: {marketplace.url}", + style=OPENHANDS_THEME.foreground, + ) + # For now, just update the timestamp + # In the future, this would fetch and cache the marketplace index + storage.update_marketplace_timestamp(marketplace.url) + console.print( + f" Updated: {marketplace.url}", + style=OPENHANDS_THEME.success, + ) + + console.print( + f"Successfully updated {len(marketplaces)} marketplace(s)", + style=OPENHANDS_THEME.success, + ) + + 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..3d054a1b7 --- /dev/null +++ b/openhands_cli/plugins/marketplace_storage.py @@ -0,0 +1,197 @@ +"""Storage module for plugin marketplace configurations. + +This module handles persistence of marketplace URLs to ~/.openhands/marketplaces.json +""" + +import json +import os +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from openhands_cli.locations import MARKETPLACES_FILE, PERSISTENCE_DIR + + +class MarketplaceError(Exception): + """Exception raised for marketplace-related errors.""" + + pass + + +@dataclass +class Marketplace: + """Represents a plugin marketplace configuration.""" + + url: str + name: str | None = None + added_at: str = field(default_factory=lambda: datetime.now().isoformat()) + last_updated: str | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "url": self.url, + "name": self.name, + "added_at": self.added_at, + "last_updated": self.last_updated, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Marketplace": + """Create from dictionary.""" + return cls( + url=data["url"], + name=data.get("name"), + added_at=data.get("added_at", datetime.now().isoformat()), + last_updated=data.get("last_updated"), + ) + + +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: + data = json.load(f) + # 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() + return [Marketplace.from_dict(m) for m in config.get("marketplaces", [])] + + def add_marketplace(self, url: str, name: str | None = None) -> Marketplace: + """Add a new marketplace. + + Args: + url: URL of the marketplace. + name: Optional friendly name for the marketplace. + + Returns: + The created Marketplace object. + + Raises: + MarketplaceError: If the marketplace already exists. + """ + config = self._load_config() + marketplaces = config.get("marketplaces", []) + + # Check if marketplace already exists + for m in marketplaces: + if m["url"] == url: + raise MarketplaceError(f"Marketplace already exists: {url}") + + # Create new marketplace + marketplace = Marketplace(url=url, name=name) + marketplaces.append(marketplace.to_dict()) + config["marketplaces"] = marketplaces + + self._save_config(config) + return marketplace + + def remove_marketplace(self, url: str) -> None: + """Remove a marketplace by URL. + + Args: + url: URL of the marketplace to remove. + + Raises: + MarketplaceError: If the marketplace is not found. + """ + config = self._load_config() + marketplaces = config.get("marketplaces", []) + + # Find and remove the marketplace + original_count = len(marketplaces) + marketplaces = [m for m in marketplaces if m["url"] != url] + + if len(marketplaces) == original_count: + raise MarketplaceError(f"Marketplace not found: {url}") + + config["marketplaces"] = marketplaces + self._save_config(config) + + def get_marketplace(self, url: str) -> Marketplace | None: + """Get a marketplace by URL. + + Args: + url: URL of the marketplace. + + Returns: + Marketplace object if found, None otherwise. + """ + config = self._load_config() + for m in config.get("marketplaces", []): + if m["url"] == url: + return Marketplace.from_dict(m) + return None + + def update_marketplace_timestamp(self, url: str) -> None: + """Update the last_updated timestamp for a marketplace. + + Args: + url: URL of the marketplace to update. + + Raises: + MarketplaceError: If the marketplace is not found. + """ + config = self._load_config() + marketplaces = config.get("marketplaces", []) + + found = False + for m in marketplaces: + if m["url"] == url: + m["last_updated"] = datetime.now().isoformat() + found = True + break + + if not found: + raise MarketplaceError(f"Marketplace not found: {url}") + + config["marketplaces"] = marketplaces + self._save_config(config) From 09f41914d5ceebb7e678e19e17c209aae7d98dc1 Mon Sep 17 00:00:00 2001 From: timon0305 Date: Mon, 26 Jan 2026 20:17:07 +0100 Subject: [PATCH 2/2] feat(plugin): refactor marketplace to use name-based identification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use name as primary identifier instead of URL for all operations - Add MarketplaceSource dataclass with support for github, git, url types - Add GitHub shorthand parsing (owner/repo → github:owner/repo) - Auto-generate default names from source URLs - Implement marketplace index fetching with HTTP support - Add local caching of fetched indexes - Show plugin count from cached indexes in list command - Update parser to use 'source' for add, 'name' for remove/update --- openhands_cli/argparsers/plugin_parser.py | 90 ++++-- openhands_cli/plugins/marketplace_commands.py | 96 ++++-- openhands_cli/plugins/marketplace_storage.py | 303 +++++++++++++++--- 3 files changed, 379 insertions(+), 110 deletions(-) diff --git a/openhands_cli/argparsers/plugin_parser.py b/openhands_cli/argparsers/plugin_parser.py index 9d53a5f4d..05bc808c7 100644 --- a/openhands_cli/argparsers/plugin_parser.py +++ b/openhands_cli/argparsers/plugin_parser.py @@ -32,23 +32,32 @@ def add_plugin_parser(subparsers: argparse._SubParsersAction) -> argparse.Argume Examples: - # Add a plugin marketplace - openhands plugin marketplace add https://plugins.openhands.ai/index.json + # Add a plugin marketplace using GitHub shorthand + openhands plugin marketplace add company/plugins - # Add a marketplace with a friendly name - openhands plugin marketplace add https://plugins.openhands.ai/index.json --name "Official" + # 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 - openhands plugin marketplace remove https://plugins.openhands.ai/index.json + # Remove a marketplace by name + openhands plugin marketplace remove company-plugins - # Update marketplace indexes + # Update all marketplace indexes openhands plugin marketplace update # Update a specific marketplace - openhands plugin marketplace update https://plugins.openhands.ai/index.json + openhands plugin marketplace update company-plugins """ plugin_parser = subparsers.add_parser( "plugin", @@ -83,25 +92,31 @@ def _add_marketplace_parser( marketplace_description = """ Manage plugin marketplaces (registries). -Marketplaces are URLs that provide an index of available plugins. -Configure marketplaces to discover and install plugins. +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 - openhands plugin marketplace add https://plugins.openhands.ai/index.json + # Add a marketplace using GitHub shorthand + openhands plugin marketplace add company/plugins - # Add with a name - openhands plugin marketplace add https://plugins.openhands.ai/index.json --name "Official" + # Add with a custom name + openhands plugin marketplace add company/plugins --name my-plugins # List all marketplaces openhands plugin marketplace list - # Remove a marketplace - openhands plugin marketplace remove https://plugins.openhands.ai/index.json + # 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", @@ -120,13 +135,25 @@ def _add_marketplace_parser( 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 a marketplace - openhands plugin marketplace add https://plugins.openhands.ai/index.json + # Add using GitHub shorthand + openhands plugin marketplace add company/plugins + + # Add with explicit GitHub prefix + openhands plugin marketplace add github:openhands/community-plugins - # Add with a friendly name - openhands plugin marketplace add https://plugins.openhands.ai/index.json --name "Official" + # 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", @@ -135,13 +162,13 @@ def _add_marketplace_parser( formatter_class=argparse.RawDescriptionHelpFormatter, ) add_parser.add_argument( - "url", - help="URL of the marketplace index (e.g., https://plugins.example.com/index.json)", + "source", + help="Marketplace source (GitHub: owner/repo, Git URL, or direct URL)", ) add_parser.add_argument( "--name", "-n", - help="Friendly name for the marketplace", + help="Custom name for the marketplace (auto-generated if not provided)", ) # marketplace remove command @@ -150,8 +177,8 @@ def _add_marketplace_parser( Examples: - # Remove a marketplace by URL - openhands plugin marketplace remove https://plugins.openhands.ai/index.json + # Remove a marketplace by name + openhands plugin marketplace remove company-plugins """ remove_parser = marketplace_subparsers.add_parser( "remove", @@ -160,14 +187,17 @@ def _add_marketplace_parser( formatter_class=argparse.RawDescriptionHelpFormatter, ) remove_parser.add_argument( - "url", - help="URL of the marketplace to remove", + "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 @@ -192,7 +222,7 @@ def _add_marketplace_parser( openhands plugin marketplace update # Update a specific marketplace - openhands plugin marketplace update https://plugins.openhands.ai/index.json + openhands plugin marketplace update company-plugins """ update_parser = marketplace_subparsers.add_parser( "update", @@ -201,9 +231,9 @@ def _add_marketplace_parser( formatter_class=argparse.RawDescriptionHelpFormatter, ) update_parser.add_argument( - "url", + "name", nargs="?", - help="URL of specific marketplace to update (optional, updates all if not specified)", + help="Name of specific marketplace to update (optional, updates all if not specified)", ) return marketplace_parser diff --git a/openhands_cli/plugins/marketplace_commands.py b/openhands_cli/plugins/marketplace_commands.py index dae8561d0..7c2538607 100644 --- a/openhands_cli/plugins/marketplace_commands.py +++ b/openhands_cli/plugins/marketplace_commands.py @@ -28,13 +28,12 @@ def handle_marketplace_add(args: argparse.Namespace) -> None: storage = MarketplaceStorage() try: name = getattr(args, "name", None) - marketplace = storage.add_marketplace(url=args.url, name=name) + marketplace = storage.add_marketplace(source_str=args.source, name=name) console.print( - f"Successfully added marketplace: {args.url}", + f"Added marketplace '{marketplace.name}'", style=OPENHANDS_THEME.success, ) - if marketplace.name: - console.print(f" Name: {marketplace.name}", style=OPENHANDS_THEME.secondary) + 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) @@ -48,9 +47,9 @@ def handle_marketplace_remove(args: argparse.Namespace) -> None: """ storage = MarketplaceStorage() try: - storage.remove_marketplace(url=args.url) + storage.remove_marketplace(name=args.name) console.print( - f"Successfully removed marketplace: {args.url}", + f"Removed marketplace '{args.name}'", style=OPENHANDS_THEME.success, ) except MarketplaceError as e: @@ -70,35 +69,56 @@ def handle_marketplace_list(_args: argparse.Namespace) -> None: if not marketplaces: console.print( - "No plugin marketplaces configured", style=OPENHANDS_THEME.warning + "No plugin marketplaces configured.", style=OPENHANDS_THEME.warning ) console.print( - "Use [bold]openhands plugin marketplace add [/bold] " - "to add a marketplace", + "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("URL", style="cyan", no_wrap=True) - table.add_column("Name", style="green") + 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.url, - m.name or "-", + 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) @@ -117,39 +137,57 @@ def handle_marketplace_update(args: argparse.Namespace) -> None: if not marketplaces: console.print( - "No plugin marketplaces configured", style=OPENHANDS_THEME.warning + "No plugin marketplaces configured.", style=OPENHANDS_THEME.warning ) return - # Filter by URL if specified - url_filter = getattr(args, "url", None) - if url_filter: - marketplaces = [m for m in marketplaces if m.url == url_filter] + # 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: {url_filter}", + 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.url}", + f"Updating marketplace '{marketplace.name}'...", style=OPENHANDS_THEME.foreground, ) - # For now, just update the timestamp - # In the future, this would fetch and cache the marketplace index - storage.update_marketplace_timestamp(marketplace.url) + 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" Updated: {marketplace.url}", + f"Successfully updated {updated_count} marketplace(s).", style=OPENHANDS_THEME.success, ) - - console.print( - f"Successfully updated {len(marketplaces)} 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) diff --git a/openhands_cli/plugins/marketplace_storage.py b/openhands_cli/plugins/marketplace_storage.py index 3d054a1b7..3de1a1faa 100644 --- a/openhands_cli/plugins/marketplace_storage.py +++ b/openhands_cli/plugins/marketplace_storage.py @@ -1,16 +1,22 @@ """Storage module for plugin marketplace configurations. -This module handles persistence of marketplace URLs to ~/.openhands/marketplaces.json +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.""" @@ -18,35 +24,139 @@ class MarketplaceError(Exception): 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.""" - url: str - name: str | None = None + 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 { - "url": self.url, - "name": self.name, + "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, data: dict[str, Any]) -> "Marketplace": + def from_dict(cls, name: str, data: dict[str, Any]) -> "Marketplace": """Create from dictionary.""" return cls( - url=data["url"], - name=data.get("name"), + 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.""" @@ -69,14 +179,17 @@ def _load_config(self) -> dict[str, Any]: Dictionary containing marketplace configurations. """ if not os.path.exists(self.config_path): - return {"marketplaces": []} + return {"marketplaces": {}} try: with open(self.config_path, encoding="utf-8") as f: - data = json.load(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"] = [] + data["marketplaces"] = {} return data except json.JSONDecodeError as e: raise MarketplaceError(f"Invalid JSON in config file: {e}") @@ -103,14 +216,20 @@ def list_marketplaces(self) -> list[Marketplace]: List of Marketplace objects. """ config = self._load_config() - return [Marketplace.from_dict(m) for m in config.get("marketplaces", [])] - - def add_marketplace(self, url: str, name: str | None = None) -> Marketplace: + 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: - url: URL of the marketplace. - name: Optional friendly name for the marketplace. + source_str: Source string (GitHub shorthand, Git URL, or direct URL). + name: Optional custom name for the marketplace. Returns: The created Marketplace object. @@ -119,79 +238,161 @@ def add_marketplace(self, url: str, name: str | None = None) -> Marketplace: MarketplaceError: If the marketplace already exists. """ config = self._load_config() - marketplaces = config.get("marketplaces", []) + 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 - for m in marketplaces: - if m["url"] == url: - raise MarketplaceError(f"Marketplace already exists: {url}") + if marketplace_name in marketplaces: + raise MarketplaceError(f"Marketplace already exists: {marketplace_name}") # Create new marketplace - marketplace = Marketplace(url=url, name=name) - marketplaces.append(marketplace.to_dict()) + 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, url: str) -> None: - """Remove a marketplace by URL. + def remove_marketplace(self, name: str) -> None: + """Remove a marketplace by name. Args: - url: URL of the marketplace to remove. + name: Name of the marketplace to remove. Raises: MarketplaceError: If the marketplace is not found. """ config = self._load_config() - marketplaces = config.get("marketplaces", []) - - # Find and remove the marketplace - original_count = len(marketplaces) - marketplaces = [m for m in marketplaces if m["url"] != url] + marketplaces = config.get("marketplaces", {}) - if len(marketplaces) == original_count: - raise MarketplaceError(f"Marketplace not found: {url}") + 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, url: str) -> Marketplace | None: - """Get a marketplace by URL. + def get_marketplace(self, name: str) -> Marketplace | None: + """Get a marketplace by name. Args: - url: URL of the marketplace. + name: Name of the marketplace. Returns: Marketplace object if found, None otherwise. """ config = self._load_config() - for m in config.get("marketplaces", []): - if m["url"] == url: - return Marketplace.from_dict(m) + marketplaces = config.get("marketplaces", {}) + + if name in marketplaces: + return Marketplace.from_dict(name, marketplaces[name]) return None - def update_marketplace_timestamp(self, url: str) -> None: - """Update the last_updated timestamp for a marketplace. + 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: - url: URL of the marketplace to update. + name: Name of the marketplace to update. + + Returns: + The fetched marketplace index data. Raises: - MarketplaceError: If the marketplace is not found. + MarketplaceError: If the marketplace is not found or fetch fails. """ config = self._load_config() - marketplaces = config.get("marketplaces", []) + marketplaces = config.get("marketplaces", {}) - found = False - for m in marketplaces: - if m["url"] == url: - m["last_updated"] = datetime.now().isoformat() - found = True - break + if name not in marketplaces: + raise MarketplaceError(f"Marketplace not found: {name}") - if not found: - raise MarketplaceError(f"Marketplace not found: {url}") + 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