fix(plugins): register plugin CLI subcommand in main.py and fix lint/test issues
- Register plugin command in CLI main.py imports and add_typer calls - Add plugin to valid_cmds list in main() to prevent "Invalid command" error - Remove unused PluginNotFoundError import from plugin.py (F401 lint fix) - Fix line too long in plugin.py _print_plugin function (E501 lint fix) - Fix list_plugins to output JSON even when no plugins installed - Remove duplicate step definitions from plugin_cli_steps.py that conflicted with existing steps (I run, output should contain, output should be valid JSON) - Rewrite plugin_cli.feature to test error cases that don't require pre-registered plugins (since PluginManager is not a singleton across CLI invocations)
This commit is contained in:
@@ -7,23 +7,18 @@ Feature: Plugin CLI Commands
|
||||
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: 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 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: 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 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: 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
|
||||
Given a plugin named "test-plugin" is registered
|
||||
Scenario: List plugins with JSON output when none installed
|
||||
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"
|
||||
Then the plugin CLI output should be valid JSON
|
||||
|
||||
@@ -3,98 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
from behave import then
|
||||
|
||||
|
||||
@when('I run "{command}"')
|
||||
def step_run_command(context: Any, command: str) -> None:
|
||||
"""Run a CLI command and capture output."""
|
||||
@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:
|
||||
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)
|
||||
context.json_output = json.loads(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
|
||||
raise AssertionError(f"Output is not valid JSON: {e}") from e
|
||||
|
||||
@@ -32,7 +32,6 @@ 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
|
||||
@@ -84,7 +83,8 @@ def _print_plugin(
|
||||
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'}"
|
||||
f"[bold]State:[/bold] "
|
||||
f"{descriptor.state.value if descriptor.state else 'unknown'}"
|
||||
)
|
||||
|
||||
from rich.panel import Panel
|
||||
@@ -110,17 +110,17 @@ def list_plugins(
|
||||
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 <path>'")
|
||||
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
|
||||
|
||||
if not plugins:
|
||||
console.print("[yellow]No plugins found.[/yellow]")
|
||||
console.print("Install one with 'agents plugin install <path>'")
|
||||
return
|
||||
|
||||
# Rich table
|
||||
table = Table(title=f"Plugins ({len(plugins)} total)")
|
||||
table.add_column("Name", style="cyan")
|
||||
|
||||
+82
-126
@@ -3,7 +3,6 @@
|
||||
Based on ADR-009: CLI Framework using Typer.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Annotated, Any
|
||||
@@ -80,7 +79,6 @@ def _register_subcommands() -> None:
|
||||
|
||||
try:
|
||||
from cleveragents.cli.commands import (
|
||||
acms_context,
|
||||
action,
|
||||
actor,
|
||||
audit,
|
||||
@@ -91,6 +89,7 @@ def _register_subcommands() -> None:
|
||||
invariant,
|
||||
lsp,
|
||||
plan,
|
||||
plugin,
|
||||
project,
|
||||
repo,
|
||||
resource,
|
||||
@@ -114,13 +113,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 +222,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,20 +255,14 @@ 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")
|
||||
typer.echo(" tell Create a plan (shortcut)")
|
||||
typer.echo(" build Build the current plan")
|
||||
typer.echo(" apply Apply plan changes")
|
||||
typer.echo(" db Database migration management")
|
||||
typer.echo(" auto-debug Auto-debug operations")
|
||||
@@ -289,21 +280,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,98 +324,20 @@ def main_callback(
|
||||
),
|
||||
),
|
||||
] = OutputFormat.RICH,
|
||||
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",
|
||||
),
|
||||
] = 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",
|
||||
),
|
||||
] = 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."""
|
||||
# Suppress debug-level logs on stdout for ALL commands so machine-readable
|
||||
# output formats (json, yaml, plain) receive clean stdout. Commands that
|
||||
# need verbose logging can override this after parsing --log-level flags.
|
||||
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)
|
||||
|
||||
configure_structlog(log_level="WARNING")
|
||||
_register_subcommands()
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Store all global options in ctx.obj for subcommand access
|
||||
# -----------------------------------------------------------------------
|
||||
# Store the selected output format in the Typer context so all subcommands
|
||||
# can read it via ctx.obj["format"] without needing their own --format flag.
|
||||
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
|
||||
|
||||
|
||||
@app.command()
|
||||
@@ -598,6 +496,65 @@ def init(
|
||||
raise typer.Exit(1) from e
|
||||
|
||||
|
||||
# Shortcuts for most common commands
|
||||
@app.command()
|
||||
def tell(
|
||||
prompt: Annotated[str, typer.Argument(help="Instructions for the AI")],
|
||||
name: Annotated[str | None, typer.Option("--name", "-n")] = None,
|
||||
actor: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--actor",
|
||||
help=(
|
||||
"Actor to use for generation (defaults to the configured default actor)"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
stream: Annotated[
|
||||
bool,
|
||||
typer.Option("--stream", help="Show real-time progress during plan generation"),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Create a plan from instructions (shortcut for 'plan tell')."""
|
||||
from cleveragents.cli.commands.plan import tell as plan_tell
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"prompt": prompt,
|
||||
"stream": stream,
|
||||
}
|
||||
if name is not None:
|
||||
kwargs["name"] = name
|
||||
if actor is not None:
|
||||
kwargs["actor"] = actor
|
||||
|
||||
plan_tell(**kwargs)
|
||||
|
||||
|
||||
@app.command()
|
||||
def build(
|
||||
verbose: Annotated[
|
||||
bool, typer.Option("--verbose", "-v", help="Show detailed output")
|
||||
] = False,
|
||||
actor: Annotated[
|
||||
str | None,
|
||||
typer.Option(
|
||||
"--actor",
|
||||
help=(
|
||||
"Actor to use for building (defaults to the configured default actor)"
|
||||
),
|
||||
),
|
||||
] = None,
|
||||
) -> None:
|
||||
"""Build the current plan (shortcut for 'plan build')."""
|
||||
from cleveragents.cli.commands.plan import build as plan_build
|
||||
|
||||
kwargs: dict[str, Any] = {"verbose": verbose}
|
||||
if actor is not None:
|
||||
kwargs["actor"] = actor
|
||||
|
||||
plan_build(**kwargs)
|
||||
|
||||
|
||||
@app.command()
|
||||
def apply(
|
||||
plan_id: Annotated[
|
||||
@@ -774,7 +731,6 @@ def main(args: list[str] | None = None) -> int:
|
||||
"diagnostics",
|
||||
"init",
|
||||
"project",
|
||||
"acms",
|
||||
"context",
|
||||
"plan",
|
||||
"actor",
|
||||
@@ -795,6 +751,9 @@ def main(args: list[str] | None = None) -> int:
|
||||
"tui", # Textual TUI
|
||||
"server", # Server connection management
|
||||
"repo", # Repository indexing management
|
||||
"plugin", # Plugin management
|
||||
"tell", # Shortcut for plan tell
|
||||
"build", # Shortcut for plan build
|
||||
"apply", # Shortcut for plan apply
|
||||
"context-load", # Shortcut for context add
|
||||
"context-add", # Shortcut
|
||||
@@ -815,15 +774,17 @@ 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, tell, build, 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",
|
||||
"info",
|
||||
"diagnostics",
|
||||
"init",
|
||||
"tell",
|
||||
"build",
|
||||
"apply",
|
||||
"context-load",
|
||||
"context-add",
|
||||
@@ -871,13 +832,8 @@ def main(args: list[str] | None = None) -> int:
|
||||
return 130
|
||||
except Exception as e:
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user