Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions openhands_cli/argparsers/main_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
103 changes: 103 additions & 0 deletions openhands_cli/argparsers/plugin_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Plugin subcommand argument parser for OpenHands CLI."""

import argparse


def add_plugin_parser(subparsers: argparse._SubParsersAction) -> None:
"""Add the plugin subcommand parser.

Args:
subparsers: The subparsers action to add the plugin parser to
"""
plugin_parser = subparsers.add_parser(
"plugin",
help="Manage installed plugins",
description="Install, uninstall, list, and update plugins",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
openhands plugin list # List installed plugins
openhands plugin install github:owner/repo # Install from GitHub
openhands plugin install github:owner/repo --ref v1.0.0 # Install specific version
openhands plugin uninstall plugin-name # Uninstall a plugin
openhands plugin update plugin-name # Update a plugin

Supported sources:
github:owner/repo GitHub repository shorthand
https://github.com/owner/repo Full git URL
git@github.com:owner/repo.git SSH git URL
/local/path Local directory path
""",
)

plugin_subparsers = plugin_parser.add_subparsers(
dest="plugin_command",
help="Plugin management commands",
)

# List command
list_parser = plugin_subparsers.add_parser(
"list",
help="List installed plugins",
description="Display all plugins installed in ~/.openhands/plugins/installed/",
)
list_parser.add_argument(
"--json",
action="store_true",
help="Output in JSON format",
)

# Install command
install_parser = plugin_subparsers.add_parser(
"install",
help="Install a plugin from a source",
description="Install a plugin from GitHub, git URL, or local path",
)
install_parser.add_argument(
"source",
type=str,
help=(
"Plugin source: 'github:owner/repo', git URL, or local path. "
"Examples: 'github:owner/my-plugin', 'https://github.com/owner/repo'"
),
)
install_parser.add_argument(
"--ref",
type=str,
help="Branch, tag, or commit to install (default: latest)",
)
install_parser.add_argument(
"--repo-path",
type=str,
help="Subdirectory path within the repository (for monorepos)",
)
install_parser.add_argument(
"--force",
"-f",
action="store_true",
help="Overwrite existing installation",
)

# Uninstall command
uninstall_parser = plugin_subparsers.add_parser(
"uninstall",
help="Uninstall a plugin",
description="Remove an installed plugin by name",
)
uninstall_parser.add_argument(
"name",
type=str,
help="Name of the plugin to uninstall",
)

# Update command
update_parser = plugin_subparsers.add_parser(
"update",
help="Update an installed plugin",
description="Update a plugin to the latest version from its original source",
)
update_parser.add_argument(
"name",
type=str,
help="Name of the plugin to update",
)
5 changes: 5 additions & 0 deletions openhands_cli/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,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.plugin.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:
Expand Down
6 changes: 6 additions & 0 deletions openhands_cli/plugin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Plugin management module for OpenHands CLI."""

from openhands_cli.plugin.commands import handle_plugin_command


__all__ = ["handle_plugin_command"]
220 changes: 220 additions & 0 deletions openhands_cli/plugin/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""Plugin command handlers for OpenHands CLI."""

from __future__ import annotations

import json
import sys
from argparse import Namespace

from rich.console import Console
from rich.table import Table

from openhands_cli.theme import OPENHANDS_THEME


console = Console()


def handle_plugin_command(args: Namespace) -> None:
"""Handle plugin subcommand.

Args:
args: Parsed command line arguments
"""
if args.plugin_command is None:
# No subcommand specified, show help
console.print(
"Usage: openhands plugin <command> [options]",
style=OPENHANDS_THEME.secondary,
)
console.print("\nCommands:")
console.print(" list List installed plugins")
console.print(" install Install a plugin from a source")
console.print(" uninstall Uninstall a plugin")
console.print(" update Update an installed plugin")
console.print("\nRun 'openhands plugin <command> --help' for more information.")
return

match args.plugin_command:
case "list":
_handle_list(args)
case "install":
_handle_install(args)
case "uninstall":
_handle_uninstall(args)
case "update":
_handle_update(args)


def _handle_list(args: Namespace) -> None:
"""Handle 'plugin list' command."""
from openhands.sdk.plugin.installed import (
get_installed_plugins_dir,
list_installed_plugins,
)

try:
plugins = list_installed_plugins()
plugins_dir = get_installed_plugins_dir()

if args.json:
# JSON output
output = {
"plugins_dir": str(plugins_dir),
"plugins": [p.model_dump() for p in plugins],
}
print(json.dumps(output, indent=2))
return

# Rich table output
if not plugins:
console.print(
f"No plugins installed in {plugins_dir}",
style=OPENHANDS_THEME.secondary,
)
return

primary = OPENHANDS_THEME.primary
console.print(f"\n[bold {primary}]Installed Plugins[/bold {primary}]")
console.print(f"[dim]Location: {plugins_dir}[/dim]\n")

table = Table(show_header=True, header_style=OPENHANDS_THEME.primary)
table.add_column("Name", style=OPENHANDS_THEME.secondary)
table.add_column("Version")
table.add_column("Description")
table.add_column("Source", style="dim")

for plugin in plugins:
source_display = plugin.source
if plugin.resolved_ref:
source_display += f" ({plugin.resolved_ref[:8]})"
table.add_row(
plugin.name,
plugin.version,
plugin.description or "",
source_display,
)

console.print(table)

except Exception as e:
console.print(f"Error listing plugins: {e}", style=OPENHANDS_THEME.error)
sys.exit(1)


def _handle_install(args: Namespace) -> None:
"""Handle 'plugin install' command."""
from openhands.sdk.plugin.fetch import PluginFetchError
from openhands.sdk.plugin.installed import install_plugin

source = args.source
ref = args.ref
repo_path = args.repo_path
force = args.force

console.print(
f"Installing plugin from {source}...",
style=OPENHANDS_THEME.secondary,
)

try:
info = install_plugin(
source=source,
ref=ref,
repo_path=repo_path,
force=force,
)

console.print(
f"\n[bold {OPENHANDS_THEME.success}]✓ Successfully installed "
f"'{info.name}' v{info.version}[/bold {OPENHANDS_THEME.success}]"
)
if info.description:
console.print(f" {info.description}", style="dim")
console.print(f" Source: {info.source}", style="dim")
if info.resolved_ref:
console.print(f" Ref: {info.resolved_ref[:8]}", style="dim")
console.print(f" Installed to: {info.install_path}", style="dim")

except FileExistsError as e:
console.print(f"\n{e}", style=OPENHANDS_THEME.warning)
console.print(
"Use --force to overwrite the existing installation.",
style=OPENHANDS_THEME.secondary,
)
sys.exit(1)
except PluginFetchError as e:
console.print(f"\nFailed to fetch plugin: {e}", style=OPENHANDS_THEME.error)
sys.exit(1)
except Exception as e:
console.print(f"\nError installing plugin: {e}", style=OPENHANDS_THEME.error)
sys.exit(1)


def _handle_uninstall(args: Namespace) -> None:
"""Handle 'plugin uninstall' command."""
from openhands.sdk.plugin.installed import uninstall_plugin

name = args.name

console.print(
f"Uninstalling plugin '{name}'...",
style=OPENHANDS_THEME.secondary,
)

try:
success = uninstall_plugin(name)

if success:
console.print(
f"\n[bold {OPENHANDS_THEME.success}]✓ Successfully uninstalled "
f"'{name}'[/bold {OPENHANDS_THEME.success}]"
)
else:
console.print(
f"\nPlugin '{name}' is not installed.",
style=OPENHANDS_THEME.warning,
)
sys.exit(1)

except Exception as e:
console.print(f"\nError uninstalling plugin: {e}", style=OPENHANDS_THEME.error)
sys.exit(1)


def _handle_update(args: Namespace) -> None:
"""Handle 'plugin update' command."""
from openhands.sdk.plugin.fetch import PluginFetchError
from openhands.sdk.plugin.installed import update_plugin

name = args.name

console.print(
f"Updating plugin '{name}'...",
style=OPENHANDS_THEME.secondary,
)

try:
info = update_plugin(name)

if info is None:
console.print(
f"\nPlugin '{name}' is not installed.",
style=OPENHANDS_THEME.warning,
)
sys.exit(1)

console.print(
f"\n[bold {OPENHANDS_THEME.success}]✓ Successfully updated "
f"'{info.name}' to v{info.version}[/bold {OPENHANDS_THEME.success}]"
)
if info.resolved_ref:
console.print(f" New ref: {info.resolved_ref[:8]}", style="dim")

except PluginFetchError as e:
err_style = OPENHANDS_THEME.error
console.print(f"\nFailed to fetch plugin update: {e}", style=err_style)
sys.exit(1)
except Exception as e:
console.print(f"\nError updating plugin: {e}", style=OPENHANDS_THEME.error)
sys.exit(1)
Loading
Loading