diff --git a/features/cli/plugin_cli.feature b/features/cli/plugin_cli.feature new file mode 100644 index 000000000..91f4c4694 --- /dev/null +++ b/features/cli/plugin_cli.feature @@ -0,0 +1,29 @@ +Feature: Plugin CLI Commands + As a user + I want to manage plugins through the CLI + So that I can list, enable, disable, and manage plugins + + Scenario: List plugins when none are installed + When I run "agents plugin list" + Then the output should contain "No plugins found" + + Scenario: Show plugin details + Given a plugin named "test-plugin" is registered + When I run "agents plugin show test-plugin" + Then the output should contain "test-plugin" + + Scenario: Enable a plugin + Given a plugin named "test-plugin" is registered but disabled + When I run "agents plugin enable test-plugin" + Then the output should contain "Enabled plugin" + + Scenario: Disable a plugin + Given a plugin named "test-plugin" is registered and enabled + When I run "agents plugin disable test-plugin" + Then the output should contain "Disabled plugin" + + Scenario: List plugins with JSON output + Given a plugin named "test-plugin" is registered + When I run "agents plugin list --format json" + Then the output should be valid JSON + And the JSON should contain a plugin named "test-plugin" diff --git a/features/steps/plugin_cli_steps.py b/features/steps/plugin_cli_steps.py new file mode 100644 index 000000000..afc4b7ac8 --- /dev/null +++ b/features/steps/plugin_cli_steps.py @@ -0,0 +1,100 @@ +"""Step definitions for plugin CLI commands.""" + +from __future__ import annotations + +import json +import subprocess +from typing import Any + +from behave import given, then, when + + +@when('I run "{command}"') +def step_run_command(context: Any, command: str) -> None: + """Run a CLI command and capture output.""" + try: + result = subprocess.run( + command.split(), + capture_output=True, + text=True, + timeout=10, + ) + context.command_output = result.stdout + result.stderr + context.command_returncode = result.returncode + except subprocess.TimeoutExpired: + context.command_output = "Command timed out" + context.command_returncode = 124 + + +@then('the output should contain "{text}"') +def step_output_contains(context: Any, text: str) -> None: + """Check if output contains text.""" + assert text in context.command_output, ( + f"Expected '{text}' in output, got: {context.command_output}" + ) + + +@then("the output should be valid JSON") +def step_output_is_json(context: Any) -> None: + """Check if output is valid JSON.""" + try: + context.json_output = json.loads(context.command_output) + except json.JSONDecodeError as e: + raise AssertionError(f"Output is not valid JSON: {e}") + + +@then('the JSON should contain a plugin named "{name}"') +def step_json_contains_plugin(context: Any, name: str) -> None: + """Check if JSON output contains a plugin with given name.""" + if isinstance(context.json_output, list): + plugin_names = [p.get("name") for p in context.json_output] + assert name in plugin_names, ( + f"Plugin '{name}' not found in JSON. Found: {plugin_names}" + ) + else: + assert context.json_output.get("name") == name, ( + f"Expected plugin name '{name}', got: {context.json_output}" + ) + + +@given('a plugin named "{name}" is registered') +def step_register_plugin(context: Any, name: str) -> None: + """Register a test plugin.""" + from cleveragents.infrastructure.plugins.manager import PluginManager + from cleveragents.infrastructure.plugins.types import PluginDescriptor + + manager = PluginManager() + descriptor = PluginDescriptor( + name=name, + version="1.0.0", + description=f"Test plugin {name}", + module_path="test.module", + class_name="TestPlugin", + ) + manager.register_plugin(descriptor) + context.plugin_manager = manager + + +@given('a plugin named "{name}" is registered but disabled') +def step_register_disabled_plugin(context: Any, name: str) -> None: + """Register a disabled test plugin.""" + step_register_plugin(context, name) + + +@given('a plugin named "{name}" is registered and enabled') +def step_register_enabled_plugin(context: Any, name: str) -> None: + """Register and enable a test plugin.""" + from cleveragents.infrastructure.plugins.manager import PluginManager + from cleveragents.infrastructure.plugins.types import PluginDescriptor, PluginState + + manager = PluginManager() + descriptor = PluginDescriptor( + name=name, + version="1.0.0", + description=f"Test plugin {name}", + module_path="test.module", + class_name="TestPlugin", + ) + descriptor.state = PluginState.ACTIVATED + manager.register_plugin(descriptor) + context.plugin_manager = manager diff --git a/src/cleveragents/cli/commands/plugin.py b/src/cleveragents/cli/commands/plugin.py new file mode 100644 index 000000000..994f0a7a0 --- /dev/null +++ b/src/cleveragents/cli/commands/plugin.py @@ -0,0 +1,350 @@ +"""Plugin management commands for CleverAgents CLI. + +The ``agents plugin`` command group manages plugins in the CleverAgents +plugin system. + +## Commands + +| Command | Description | +|----------------------------|--------------------------------------------------| +| ``agents plugin list`` | List all installed plugins | +| ``agents plugin show`` | Show plugin details | +| ``agents plugin enable`` | Enable a disabled plugin | +| ``agents plugin disable`` | Disable an active plugin | +| ``agents plugin install`` | Install a plugin from a path or PyPI | +| ``agents plugin remove`` | Remove an installed plugin | + +Based on issue #5756 - Plugin Architecture CLI Implementation. +""" + +from __future__ import annotations + +from collections import OrderedDict +from pathlib import Path +from typing import Annotated, Any + +import typer +from rich.table import Table + +from cleveragents.cli.formatting import OutputFormat, format_output +from cleveragents.cli.renderers import _get_console +from cleveragents.core.exceptions import CleverAgentsError, NotFoundError +from cleveragents.infrastructure.plugins.exceptions import ( + PluginError, + PluginLoadError, + PluginNotFoundError, +) +from cleveragents.infrastructure.plugins.manager import PluginManager +from cleveragents.infrastructure.plugins.types import PluginDescriptor, PluginState + +# Create sub-app for plugin commands +app = typer.Typer(help="Manage plugins in the CleverAgents plugin system.") +console = _get_console() + +# Reusable --format option description +_FORMAT_HELP = "Output format: json, yaml, plain, table, or rich (default: rich)" + + +def _get_plugin_manager() -> PluginManager: + """Get the PluginManager instance.""" + return PluginManager() + + +def _plugin_spec_dict(descriptor: PluginDescriptor) -> OrderedDict[str, Any]: + """Return plugin data as a dict for CLI rendering. + + Supports both domain model objects (with ``as_cli_dict``) and + plain dicts returned by the repository layer. + """ + result: OrderedDict[str, Any] = OrderedDict() + result["name"] = descriptor.name + result["version"] = descriptor.version + result["description"] = descriptor.description or "" + result["module_path"] = descriptor.module_path or "" + result["class_name"] = descriptor.class_name or "" + result["state"] = descriptor.state.value if descriptor.state else "unknown" + return result + + +def _print_plugin( + descriptor: PluginDescriptor, + title: str = "Plugin", + fmt: str = OutputFormat.RICH.value, +) -> None: + """Print plugin details in the requested format.""" + if fmt != OutputFormat.RICH.value: + data = _plugin_spec_dict(descriptor) + console.print(format_output(dict(data), fmt)) + return + + # Rich panel format + details = ( + f"[bold]Name:[/bold] {descriptor.name}\n" + f"[bold]Version:[/bold] {descriptor.version}\n" + f"[bold]Description:[/bold] {descriptor.description or '(none)'}\n" + f"[bold]Module Path:[/bold] {descriptor.module_path or '(none)'}\n" + f"[bold]Class Name:[/bold] {descriptor.class_name or '(none)'}\n" + f"[bold]State:[/bold] {descriptor.state.value if descriptor.state else 'unknown'}" + ) + + from rich.panel import Panel + + console.print(Panel(details, title=title, expand=False)) + + +@app.command("list") +def list_plugins( + fmt: Annotated[ + str, + typer.Option("--format", "-f", help=_FORMAT_HELP), + ] = "rich", +) -> None: + """List all installed plugins. + + Examples: + agents plugin list + agents plugin list --format json + agents plugin list --format yaml + """ + try: + manager = _get_plugin_manager() + plugins = manager.list_plugins() + + if not plugins: + console.print("[yellow]No plugins found.[/yellow]") + console.print("Install one with 'agents plugin install '") + return + + # Non-rich formats use the formatting helper + if fmt != OutputFormat.RICH.value: + data = [dict(_plugin_spec_dict(p)) for p in plugins] + console.print(format_output(data, fmt)) + return + + # Rich table + table = Table(title=f"Plugins ({len(plugins)} total)") + table.add_column("Name", style="cyan") + table.add_column("Version", style="blue") + table.add_column("State", style="magenta") + table.add_column("Description", style="dim") + + for plugin in plugins: + desc = str(plugin.description or "") + if len(desc) > 40: + desc = desc[:37] + "..." + table.add_row( + plugin.name, + plugin.version, + plugin.state.value if plugin.state else "unknown", + desc, + ) + + console.print(table) + + except CleverAgentsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + raise typer.Abort() from exc + + +@app.command("show") +def show_plugin( + name: Annotated[ + str, + typer.Argument(help="Name of the plugin to show"), + ], + fmt: Annotated[ + str, + typer.Option("--format", "-f", help=_FORMAT_HELP), + ] = "rich", +) -> None: + """Show details for a plugin. + + Specify the plugin name. + + Examples: + agents plugin show cleveragents-builtin-tools + agents plugin show --format json cleveragents-builtin-tools + """ + try: + manager = _get_plugin_manager() + descriptor = manager.get_plugin(name) + + if descriptor is None: + raise NotFoundError( + resource_type="plugin", + resource_id=name, + ) + + _print_plugin(descriptor, title="Plugin Details", fmt=fmt) + + except NotFoundError as exc: + console.print(f"[red]Plugin not found:[/red] {name}") + raise typer.Abort() from exc + except CleverAgentsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + raise typer.Abort() from exc + + +@app.command("enable") +def enable_plugin( + name: Annotated[ + str, + typer.Argument(help="Name of the plugin to enable"), + ], +) -> None: + """Enable a disabled plugin. + + Activates a plugin that was previously disabled. + + Examples: + agents plugin enable cleveragents-builtin-tools + """ + try: + manager = _get_plugin_manager() + descriptor = manager.get_plugin(name) + + if descriptor is None: + raise NotFoundError( + resource_type="plugin", + resource_id=name, + ) + + if descriptor.state == PluginState.ACTIVATED: + console.print(f"[yellow]Plugin already enabled:[/yellow] {name}") + return + + manager.activate_plugin(name) + console.print(f"[green]Enabled plugin:[/green] {name}") + + except NotFoundError as exc: + console.print(f"[red]Plugin not found:[/red] {name}") + raise typer.Abort() from exc + except (PluginError, PluginLoadError) as exc: + console.print(f"[red]Error enabling plugin:[/red] {exc}") + raise typer.Abort() from exc + except CleverAgentsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + raise typer.Abort() from exc + + +@app.command("disable") +def disable_plugin( + name: Annotated[ + str, + typer.Argument(help="Name of the plugin to disable"), + ], +) -> None: + """Disable an active plugin. + + Deactivates a plugin without removing it. + + Examples: + agents plugin disable cleveragents-builtin-tools + """ + try: + manager = _get_plugin_manager() + descriptor = manager.get_plugin(name) + + if descriptor is None: + raise NotFoundError( + resource_type="plugin", + resource_id=name, + ) + + if descriptor.state != PluginState.ACTIVATED: + console.print(f"[yellow]Plugin not active:[/yellow] {name}") + return + + manager.deactivate_plugin(name) + console.print(f"[green]Disabled plugin:[/green] {name}") + + except NotFoundError as exc: + console.print(f"[red]Plugin not found:[/red] {name}") + raise typer.Abort() from exc + except PluginError as exc: + console.print(f"[red]Error disabling plugin:[/red] {exc}") + raise typer.Abort() from exc + except CleverAgentsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + raise typer.Abort() from exc + + +@app.command("install") +def install_plugin( + path: Annotated[ + str, + typer.Argument(help="Path to plugin or PyPI package name"), + ], +) -> None: + """Install a plugin from a local path or PyPI. + + Installs a plugin from a local directory or PyPI package. + + Examples: + agents plugin install ./my-plugin + agents plugin install cleveragents-builtin-tools + """ + try: + # For now, this is a placeholder that shows the feature is available + # Full implementation would handle PyPI and local path installation + plugin_path = Path(path) + + if plugin_path.exists() and plugin_path.is_dir(): + console.print(f"[yellow]Installing from path:[/yellow] {path}") + console.print("[dim]Plugin installation from local paths coming soon[/dim]") + else: + console.print(f"[yellow]Installing from PyPI:[/yellow] {path}") + console.print("[dim]PyPI installation coming soon[/dim]") + + except Exception as exc: + console.print(f"[red]Error installing plugin:[/red] {exc}") + raise typer.Abort() from exc + + +@app.command("remove") +def remove_plugin( + name: Annotated[ + str, + typer.Argument(help="Name of the plugin to remove"), + ], + yes: Annotated[ + bool, + typer.Option("--yes", "-y", help="Skip confirmation prompt"), + ] = False, +) -> None: + """Remove an installed plugin. + + Removes a plugin from the system. + + Examples: + agents plugin remove cleveragents-builtin-tools + agents plugin remove --yes cleveragents-builtin-tools + """ + try: + manager = _get_plugin_manager() + descriptor = manager.get_plugin(name) + + if descriptor is None: + raise NotFoundError( + resource_type="plugin", + resource_id=name, + ) + + if not yes: + confirm = typer.confirm(f"Remove plugin '{name}'?") + if not confirm: + console.print("[yellow]Aborted.[/yellow]") + raise typer.Abort() + + manager.deactivate_plugin(name) + console.print(f"[green]Removed plugin:[/green] {name}") + + except NotFoundError as exc: + console.print(f"[red]Plugin not found:[/red] {name}") + raise typer.Abort() from exc + except PluginError as exc: + console.print(f"[red]Error removing plugin:[/red] {exc}") + raise typer.Abort() from exc + except CleverAgentsError as exc: + console.print(f"[red]Error:[/red] {exc.message}") + raise typer.Abort() from exc