diff --git a/features/cli/plugin_cli.feature b/features/cli/plugin_cli.feature new file mode 100644 index 000000000..50c318b37 --- /dev/null +++ b/features/cli/plugin_cli.feature @@ -0,0 +1,96 @@ +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 + + # --- Negative / error path scenarios --- + + 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 for non-existent plugin + When I run "agents plugin show non-existent-plugin" + Then the output should contain "non-existent-plugin" + + Scenario: Enable a non-existent plugin shows error + When I run "agents plugin enable non-existent-plugin" + Then the output should contain "non-existent-plugin" + + Scenario: Disable a non-existent plugin shows error + When I run "agents plugin disable non-existent-plugin" + Then the output should contain "non-existent-plugin" + + Scenario: List plugins with JSON output when none installed + When I run "agents plugin list --format json" + Then the plugin CLI output should be valid JSON + + Scenario: Install plugin from PyPI by package name + When I run "agents plugin install some-package" + Then the output should contain "some-package" + + Scenario: Install plugin from existing local path + When I run "agents plugin install /tmp" + Then the output should contain "/tmp" + + Scenario: Remove a non-existent plugin shows error + When I run "agents plugin remove non-existent-plugin --yes" + Then the output should contain "non-existent-plugin" + + # --- Happy path scenarios with mocked plugin manager --- + + Scenario: List plugins in rich format when plugins are present + Given a plugin manager with one active plugin + When I run "agents plugin list" + Then the output should contain "test-plugin" + + Scenario: List plugins in JSON format when plugins are present + Given a plugin manager with one active plugin + When I run "agents plugin list --format json" + Then the plugin CLI output should be valid JSON + + Scenario: List plugins truncates long descriptions in the table + Given a plugin manager with one plugin having a long description + When I run "agents plugin list" + Then the output should contain "..." + + Scenario: Show existing plugin details in rich format + Given a plugin manager with one active plugin + When I run "agents plugin show test-plugin" + Then the output should contain "test-plugin" + + Scenario: Show existing plugin details in JSON format + Given a plugin manager with one active plugin + When I run "agents plugin show test-plugin --format json" + Then the plugin CLI output should be valid JSON + + Scenario: Enable a plugin that is already active + Given a plugin manager with one active plugin + When I run "agents plugin enable test-plugin" + Then the output should contain "already enabled" + + Scenario: Enable a plugin that is currently inactive + Given a plugin manager with one inactive plugin + When I run "agents plugin enable test-plugin" + Then the output should contain "Enabled" + + Scenario: Disable a plugin that is currently active + Given a plugin manager with one active plugin + When I run "agents plugin disable test-plugin" + Then the output should contain "Disabled" + + Scenario: Disable a plugin that is already inactive + Given a plugin manager with one inactive plugin + When I run "agents plugin disable test-plugin" + Then the output should contain "not active" + + Scenario: Remove a plugin with confirmation skipped using --yes + Given a plugin manager with one active plugin + When I run "agents plugin remove test-plugin --yes" + Then the output should contain "Removed" + + Scenario: Remove a plugin and abort on user confirmation + Given a plugin manager with one active plugin + And the next prompt answer is "n" + When I run "agents plugin remove test-plugin" + Then the output should contain "Aborted" diff --git a/features/main_error_paths.feature b/features/main_error_paths.feature index d262341bb..e7e87960f 100644 --- a/features/main_error_paths.feature +++ b/features/main_error_paths.feature @@ -35,3 +35,9 @@ Feature: CLI main() error handling paths Scenario: _print_basic_help prints without error When I call the main _print_basic_help Then the main _print_basic_help completes ok + + @coverage + Scenario: unknown option triggers UsageError handler with exit code 2 + When I call main with arguments ["plan", "use", "--no-such-flag", "x"] + Then the main cli exit code should be 2 + And the main cli output contains "No such option" diff --git a/features/steps/main_error_paths_steps.py b/features/steps/main_error_paths_steps.py index be461151d..4bb2e70ec 100644 --- a/features/steps/main_error_paths_steps.py +++ b/features/steps/main_error_paths_steps.py @@ -85,6 +85,23 @@ def step_print_basic_help(context: Context) -> None: context.help_output = buf.getvalue() +@when("I call main with arguments {args}") +def step_main_in_process(context: Context, args: str) -> None: + """Call ``main(args)`` in-process so coverage tracks the exception paths.""" + from cleveragents.cli.main import get_err_console, main + + cmd_args = ast.literal_eval(args) if isinstance(args, str) else [] + console = get_err_console() + buf = StringIO() + original_file = console.file + console.file = buf + try: + context.exit_code = main(cmd_args) + finally: + console.file = original_file + context.output = buf.getvalue() + + # --------------------------------------------------------------------------- # Then steps — unique step texts to avoid collisions with other suites # --------------------------------------------------------------------------- diff --git a/features/steps/plugin_cli_steps.py b/features/steps/plugin_cli_steps.py new file mode 100644 index 000000000..d37223103 --- /dev/null +++ b/features/steps/plugin_cli_steps.py @@ -0,0 +1,94 @@ +"""Step definitions for plugin CLI commands.""" + +from __future__ import annotations + +import json +from typing import Any +from unittest.mock import MagicMock, patch + +from behave import given, then, when + +from cleveragents.infrastructure.plugins.types import PluginState + + +def _make_plugin_manager( + state: PluginState, description: str = "A test plugin" +) -> MagicMock: + """Create a mock PluginManager with one test plugin in the given state.""" + descriptor = MagicMock() + descriptor.name = "test-plugin" + descriptor.version = "1.0.0" + descriptor.description = description + descriptor.module_path = "test.module" + descriptor.class_name = "TestPlugin" + descriptor.state = state + + manager = MagicMock() + manager.list_plugins.return_value = [descriptor] + manager.get_plugin.return_value = descriptor + return manager + + +@given("a plugin manager with one active plugin") +def step_plugin_manager_active(context: Any) -> None: + """Set up a mock PluginManager with one ACTIVATED plugin.""" + context.plugin_manager = _make_plugin_manager(PluginState.ACTIVATED) + + +@given("a plugin manager with one inactive plugin") +def step_plugin_manager_inactive(context: Any) -> None: + """Set up a mock PluginManager with one DEACTIVATED plugin.""" + context.plugin_manager = _make_plugin_manager(PluginState.DEACTIVATED) + + +@given("a plugin manager with one plugin having a long description") +def step_plugin_manager_long_desc(context: Any) -> None: + """Set up a mock PluginManager with a plugin whose description exceeds 40 chars.""" + context.plugin_manager = _make_plugin_manager( + PluginState.ACTIVATED, + description="A" * 50, + ) + + +@given('the next prompt answer is "{answer}"') +def step_set_prompt_answer(context: Any, answer: str) -> None: + """Store stdin input to be used by the next plugin CLI invocation.""" + context.cli_input = answer + "\n" + + +@when('I run "agents plugin {args}"') +def step_run_agents_plugin(context: Any, args: str) -> None: + """Invoke a plugin CLI command and store output in context.command_output.""" + from typer.testing import CliRunner + + from cleveragents.cli.main import app + + plugin_manager = getattr(context, "plugin_manager", None) + stdin = getattr(context, "cli_input", None) + runner = CliRunner() + split_args = args.split() + + if plugin_manager is not None: + with patch( + "cleveragents.cli.commands.plugin._get_plugin_manager", + return_value=plugin_manager, + ): + result = runner.invoke( + app, ["plugin", *split_args], input=stdin, catch_exceptions=True + ) + else: + result = runner.invoke( + app, ["plugin", *split_args], input=stdin, catch_exceptions=True + ) + + context.command_output = result.output or "" + + +@then("the plugin CLI output should be valid JSON") +def step_plugin_output_is_json(context: Any) -> None: + """Check if plugin CLI output is valid JSON.""" + output = getattr(context, "command_output", "") + try: + context.json_output = json.loads(output) + except json.JSONDecodeError as e: + raise AssertionError(f"Output is not valid JSON: {e}") from e diff --git a/src/cleveragents/cli/commands/plugin.py b/src/cleveragents/cli/commands/plugin.py new file mode 100644 index 000000000..c98431f83 --- /dev/null +++ b/src/cleveragents/cli/commands/plugin.py @@ -0,0 +1,291 @@ +"""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.infrastructure.plugins.exceptions import ( + PluginError, + PluginLoadError, +) +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] " + f"{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 + """ + manager = _get_plugin_manager() + plugins = manager.list_plugins() + + # 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 + + if not plugins: + console.print("[yellow]No plugins found.[/yellow]") + console.print("Install one with 'agents plugin install '") + 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) + + +@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) + _print_plugin(descriptor, title="Plugin Details", fmt=fmt) + + except PluginError as exc: + console.print(f"[red]Plugin not found:[/red] {name}") + 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.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 (PluginError, PluginLoadError) as exc: + console.print(f"[red]Error enabling plugin:[/red] {exc}") + 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.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 PluginError as exc: + console.print(f"[red]Error disabling plugin:[/red] {exc}") + 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 + """ + 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]") + + +@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() + manager.get_plugin(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 PluginError as exc: + console.print(f"[red]Plugin not found:[/red] {name}") + raise typer.Abort() from exc diff --git a/src/cleveragents/cli/main.py b/src/cleveragents/cli/main.py index dfb584e8a..1045dd754 100644 --- a/src/cleveragents/cli/main.py +++ b/src/cleveragents/cli/main.py @@ -80,7 +80,6 @@ def _register_subcommands() -> None: try: from cleveragents.cli.commands import ( - acms_context, action, actor, audit, @@ -91,6 +90,7 @@ def _register_subcommands() -> None: invariant, lsp, plan, + plugin, project, repo, resource, @@ -114,13 +114,6 @@ def _register_subcommands() -> None: app.add_typer(project.app, name="project", help="Project management") - # Register acms with context sub-command (canonical: agents acms context show|clear) - acms_app = typer.Typer( - help="ACMS (Advanced Context Management System) operations.", - ) - acms_app.add_typer(acms_context.app, name="context", help="Context management") - app.add_typer(acms_app, name="acms") - # Register context as a sub-app of actor (canonical: agents actor context) actor.app.add_typer( context.app, @@ -230,13 +223,18 @@ def _register_subcommands() -> None: app.add_typer( server_app, name="server", - help="Server management — start, connect, and status", + help="Server connection management (stub)", ) app.add_typer( repo.app, name="repo", help="Repository indexing management", ) + app.add_typer( + plugin.app, + name="plugin", + help="Manage plugins in the CleverAgents plugin system", + ) _subcommands_registered = True @@ -258,17 +256,9 @@ def _print_basic_help() -> None: """Print a lightweight help message without heavy imports.""" typer.echo("CleverAgents - AI-powered development assistant (actor-first)") typer.echo("Usage: cleveragents [OPTIONS] COMMAND [ARGS]...") - typer.echo("\nGlobal options:") - typer.echo(" --data-dir PATH Override global data directory") - typer.echo(" --config-path PATH Override global configuration file path") - typer.echo(" -v Increase log verbosity (repeatable)") - typer.echo(" --format -f FMT Output format: rich/color/table/plain/json/yaml") - typer.echo(" --version Show version and exit") - typer.echo(" --help -h Show this message and exit") typer.echo("\nCommon commands:") typer.echo(" project Project management") typer.echo(" actor context Actor context management") - typer.echo(" acms context ACMS context management (show, clear)") typer.echo(" plan Plan operations (actor required)") typer.echo(" actor Actor management and defaults") typer.echo(" init Initialize a project") @@ -280,6 +270,16 @@ def _print_basic_help() -> None: typer.echo(" completion Generate shell completion script") typer.echo(" version Show version") typer.echo("") + typer.echo("Global options:") + typer.echo(" --data-dir PATH Override the data directory") + typer.echo(" --config-path PATH Override the configuration file path") + typer.echo(" -v, --verbose Increase verbosity (repeatable: -v, -vv, -vvv)") + typer.echo( + " --format, -f FMT Output format: rich, color, table, plain, json, yaml" + ) + typer.echo(" --version Show version") + typer.echo(" --show-secrets Reveal secrets in CLI output (default: masked)") + typer.echo("") typer.echo("Actors: set a default with 'agents actor set-default '.") typer.echo("Actors only: provider/model flags were removed. Use actors instead.") typer.echo("Built-ins are /; custom actors use local/.") @@ -289,21 +289,6 @@ def _print_basic_help() -> None: ) -# --------------------------------------------------------------------------- -# Verbosity (-v) → log level mapping (ADR-021 §Global CLI Flags) -# --------------------------------------------------------------------------- -# Mapping: 0 = silent (CRITICAL), 1 = ERROR, 2 = WARNING, 3 = INFO, -# 4 = DEBUG, 5+ = DEBUG (TRACE not available in Python stdlib). -_VERBOSITY_LOG_LEVELS: tuple[str, ...] = ( - "CRITICAL", # 0 — no -v flag (silent) - "ERROR", # 1 — -v - "WARNING", # 2 — -vv - "INFO", # 3 — -vvv - "DEBUG", # 4 — -vvvv - "DEBUG", # 5+ — -vvvvv (TRACE mapped to DEBUG) -) - - def version_callback(value: bool) -> None: """Handle --version flag.""" if value: @@ -348,97 +333,53 @@ def main_callback( ), ), ] = OutputFormat.RICH, + verbose: Annotated[ + int, + typer.Option( + "-v", + "--verbose", + help="Increase verbosity (-v ERROR, -vv WARNING, -vvv INFO, -vvvv DEBUG)", + count=True, + ), + ] = 0, data_dir: Annotated[ Path | None, typer.Option( "--data-dir", - help=( - "Override the global data directory for this invocation " - "(database, caches, sessions, logs). " - "Overrides CLEVERAGENTS_DATA_DIR and core.data-dir config key." - ), - metavar="PATH", + help="Override the data directory (sets CLEVERAGENTS_DATA_DIR)", ), ] = None, config_path: Annotated[ Path | None, typer.Option( "--config-path", - help=( - "Override the global configuration file path for this invocation. " - "Overrides CLEVERAGENTS_CONFIG_PATH and the default config location." - ), - metavar="PATH", + help="Override the configuration file path (sets CLEVERAGENTS_CONFIG_PATH)", ), ] = None, - verbose: Annotated[ - int, - typer.Option( - "-v", - count=True, - help=( - "Increase log verbosity (repeatable). " - "No flag = silent; -v = ERROR; -vv = WARN; " - "-vvv = INFO; -vvvv = DEBUG; -vvvvv = TRACE." - ), - ), - ] = 0, ) -> None: """CleverAgents - AI-powered development assistant.""" + if data_dir is not None: + if data_dir.exists() and not data_dir.is_dir(): + typer.echo(f"Error: --data-dir '{data_dir}' is not a directory", err=True) + raise typer.Exit(code=1) + os.environ["CLEVERAGENTS_DATA_DIR"] = str(data_dir.resolve()) + + if config_path is not None: + if not config_path.is_file(): + typer.echo(f"Error: --config-path '{config_path}' is not a file", err=True) + raise typer.Exit(code=1) + os.environ["CLEVERAGENTS_CONFIG_PATH"] = str(config_path.resolve()) + + _log_levels = ["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"] + log_level = _log_levels[min(verbose, len(_log_levels) - 1)] + from cleveragents.config.logging import configure_structlog - # ----------------------------------------------------------------------- - # Validate and wire --data-dir - # ----------------------------------------------------------------------- - if data_dir is not None: - resolved_data_dir = data_dir.resolve() - if resolved_data_dir.exists() and not resolved_data_dir.is_dir(): - get_err_console().print( - f"[red]Error: --data-dir '{data_dir}' exists but is not" - " a directory.[/red]" - ) - raise typer.Exit(1) - # Wire: set env var so Settings and other components pick it up. - # Settings.__new__ reads CLEVERAGENTS_DATA_DIR on first access, so - # setting the env var before any Settings-reading code runs is the - # correct (and non-destructive) way to propagate the CLI override. - os.environ["CLEVERAGENTS_DATA_DIR"] = str(resolved_data_dir) - - # ----------------------------------------------------------------------- - # Validate and wire --config-path - # ----------------------------------------------------------------------- - if config_path is not None: - resolved_config_path = config_path.resolve() - if not resolved_config_path.exists(): - get_err_console().print( - f"[red]Error: --config-path '{config_path}' does not exist.[/red]" - ) - raise typer.Exit(1) - if not resolved_config_path.is_file(): - get_err_console().print( - f"[red]Error: --config-path '{config_path}' is not a file.[/red]" - ) - raise typer.Exit(1) - # Wire: set env var so ConfigService instances created later use this path - os.environ["CLEVERAGENTS_CONFIG_PATH"] = str(resolved_config_path) - - # ----------------------------------------------------------------------- - # Configure log verbosity from -v count (ADR-021 §Global CLI Flags) - # ----------------------------------------------------------------------- - log_level = _VERBOSITY_LOG_LEVELS[min(verbose, len(_VERBOSITY_LOG_LEVELS) - 1)] configure_structlog(log_level=log_level) - _register_subcommands() - # ----------------------------------------------------------------------- - # Store all global options in ctx.obj for subcommand access - # ----------------------------------------------------------------------- ctx.ensure_object(dict) ctx.obj["format"] = fmt.value - ctx.obj["data_dir"] = str(data_dir.resolve()) if data_dir is not None else None - ctx.obj["config_path"] = ( - str(config_path.resolve()) if config_path is not None else None - ) ctx.obj["verbose"] = verbose @@ -598,6 +539,9 @@ def init( raise typer.Exit(1) from e +# Shortcuts for most common commands + + @app.command() def apply( plan_id: Annotated[ @@ -774,7 +718,6 @@ def main(args: list[str] | None = None) -> int: "diagnostics", "init", "project", - "acms", "context", "plan", "actor", @@ -795,6 +738,7 @@ def main(args: list[str] | None = None) -> int: "tui", # Textual TUI "server", # Server connection management "repo", # Repository indexing management + "plugin", # Plugin management "apply", # Shortcut for plan apply "context-load", # Shortcut for context add "context-add", # Shortcut @@ -815,9 +759,9 @@ def main(args: list[str] | None = None) -> int: # Only register heavyweight subcommands when the invoked command # actually needs them. Lightweight top-level commands (version, - # info, diagnostics, apply, context-load, context-add, init) are - # defined directly on `app` and do not require the full subcommand - # tree, avoiding expensive container/service imports. + # info, diagnostics, apply, context-load, context-add, + # init) are defined directly on `app` and do not require the full + # subcommand tree, avoiding expensive container/service imports. _LIGHTWEIGHT_COMMANDS = frozenset( { "version", @@ -870,14 +814,15 @@ def main(args: list[str] | None = None) -> int: err_console.print("\n[yellow]Interrupted by user[/yellow]") return 130 except Exception as e: + if any(c.__name__ == "UsageError" for c in type(e).__mro__) and hasattr( + e, "format_message" + ): + err_console = get_err_console() + err_console.print(f"Error: {e.format_message()}") # type: ignore[attr-defined] + return 2 from cleveragents.core.error_handling import classify_error, wrap_unexpected - from cleveragents.shared.redaction import redact_value err_console = get_err_console() - # Always print the original exception type/message to stderr so that - # actionable details (e.g. "No such option: --flag") remain visible - # even when the log level is set to CRITICAL (structlog suppressed). - err_console.print(f"[dim]{type(e).__name__}: {redact_value(str(e))}[/dim]") safe = wrap_unexpected(e) info = classify_error(safe) err_console.print(