Skip to content

ccproxy.cli.commands.plugins

ccproxy.cli.commands.plugins

CLI commands for interacting with plugins.

PluginConfigField dataclass

PluginConfigField(
    name,
    type_label,
    default_label,
    value_label,
    description,
    required,
)

Renderable representation of a plugin configuration field.

PluginMetadata dataclass

PluginMetadata(
    name,
    version,
    description,
    enabled,
    status_reason,
    config_fields,
)

Aggregated metadata and configuration for a plugin.

describe_config_model

describe_config_model(config_class, config_instance=None)

Convert a plugin config model into display-ready field metadata.

Source code in ccproxy/cli/commands/plugins.py
def describe_config_model(
    config_class: type[BaseModel] | None,
    config_instance: BaseModel | None = None,
) -> tuple[PluginConfigField, ...]:
    """Convert a plugin config model into display-ready field metadata."""

    if config_class is None:
        return ()

    fields_info: list[PluginConfigField] = []
    for field_name, field in config_class.model_fields.items():
        type_label = _format_annotation(field.annotation)
        default_label = _format_default(field)
        description = field.description or ""
        required = field.is_required()
        value_label = "—"

        if config_instance is not None:
            value = getattr(config_instance, field_name, None)
            value_label = _format_value(value)

        fields_info.append(
            PluginConfigField(
                name=field_name,
                type_label=type_label,
                default_label=default_label,
                value_label=value_label,
                description=description,
                required=required,
            )
        )

    return tuple(fields_info)

gather_plugin_metadata

gather_plugin_metadata(settings)

Collect plugin metadata and configuration for CLI display.

Source code in ccproxy/cli/commands/plugins.py
def gather_plugin_metadata(settings: Settings) -> tuple[PluginMetadata, ...]:
    """Collect plugin metadata and configuration for CLI display."""

    factories, filter_config, combined_denylist = _load_all_plugin_factories(settings)

    metadata_list: list[PluginMetadata] = []
    for name in sorted(factories):
        factory = factories[name]
        manifest = factory.get_manifest()
        config_instance = _build_config_instance(manifest, settings)
        config_fields = describe_config_model(manifest.config_class, config_instance)
        enabled = settings.enable_plugins and filter_config.is_enabled(name)
        status_reason = (
            None
            if enabled
            else _derive_status_reason(name, settings, combined_denylist)
        )

        metadata_list.append(
            PluginMetadata(
                name=name,
                version=getattr(manifest, "version", None),
                description=getattr(manifest, "description", None),
                enabled=enabled,
                status_reason=status_reason,
                config_fields=config_fields,
            )
        )

    return tuple(metadata_list)

list_plugins

list_plugins()

List all discovered plugins and high-level details.

Source code in ccproxy/cli/commands/plugins.py
@app.command(name="list")
def list_plugins() -> None:
    """List all discovered plugins and high-level details."""

    console = Console()
    settings_obj = Settings.from_config()

    plugins = gather_plugin_metadata(settings_obj)
    if not plugins:
        console.print("No plugins found.")
        return

    table = Table(
        title="Discovered Plugins",
        show_header=True,
        header_style="bold magenta",
    )
    table.add_column("Plugin", style="bold")
    table.add_column("Version", style="cyan")
    table.add_column("Status", style="green")
    table.add_column("Config Fields", style="yellow")
    table.add_column("Description", style="dim")

    for plugin in plugins:
        status = "Enabled" if plugin.enabled else "Disabled"
        if plugin.status_reason:
            status = f"{status} ({plugin.status_reason})"
        config_count = str(len(plugin.config_fields)) if plugin.config_fields else "0"
        table.add_row(
            plugin.name,
            plugin.version or "unknown",
            status,
            config_count,
            plugin.description or "",
        )

    console.print(table)

settings

settings(plugin=Argument(None, help='Plugin to inspect'))

Show configuration fields for plugins.

Source code in ccproxy/cli/commands/plugins.py
@app.command()
def settings(
    plugin: str | None = typer.Argument(None, help="Plugin to inspect"),
) -> None:
    """Show configuration fields for plugins."""
    from ccproxy.cli._settings_help import print_settings_help

    console = Console()
    settings_obj = Settings.from_config()

    plugins = gather_plugin_metadata(settings_obj)
    if not plugins:
        console.print("No plugins found.")
        return

    if plugin is not None:
        plugins = tuple(p for p in plugins if p.name == plugin)
        if not plugins:
            console.print(f"Plugin '{plugin}' not found.")
            return

    # Load plugin factories to get config classes
    factories, _filter_config, _combined_denylist = _load_all_plugin_factories(
        settings_obj
    )

    for plugin_meta in plugins:
        # Get the plugin factory and config
        factory = factories.get(plugin_meta.name)
        if not factory:
            console.print(
                f"[yellow]Warning: Could not load factory for {plugin_meta.name}[/yellow]"
            )
            continue

        manifest = factory.get_manifest()
        config_class = getattr(manifest, "config_class", None)

        if not config_class:
            console.print(f"  {plugin_meta.name}: No configuration fields declared.")
            continue

        # Get the config instance
        config_instance = _build_config_instance(manifest, settings_obj)

        # Use generic settings display
        print_settings_help(
            config_class,
            config_instance,
            version=plugin_meta.version,
            enabled=plugin_meta.enabled,
        )

dependencies

dependencies()

Display how plugin dependencies are managed.

Source code in ccproxy/cli/commands/plugins.py
@app.command()
def dependencies() -> None:
    """Display how plugin dependencies are managed."""

    console = Console()
    console.print(
        "Plugin dependencies are managed at the package level (pyproject.toml/extras)."
    )

scaffold

scaffold(
    plugin_name,
    plugin_type=SYSTEM,
    description="Custom CCProxy plugin.",
    version="0.1.0",
    output_path=None,
    include_tests=False,
    force=False,
)

Generate a plugin scaffold to jump-start development.

Source code in ccproxy/cli/commands/plugins.py
@app.command()
def scaffold(
    plugin_name: Annotated[
        str,
        typer.Argument(
            help="New plugin package name (snake_case).",
        ),
    ],
    plugin_type: Annotated[
        PluginTemplateType,
        typer.Option(
            "--type",
            "-t",
            help="Scaffold type to generate (system, provider, auth).",
            case_sensitive=False,
        ),
    ] = PluginTemplateType.SYSTEM,
    description: Annotated[
        str,
        typer.Option(
            "--description",
            "-d",
            help="Plugin description stored in the manifest.",
        ),
    ] = "Custom CCProxy plugin.",
    version: Annotated[
        str,
        typer.Option(
            "--version",
            "-v",
            help="Semver version recorded in the manifest.",
        ),
    ] = "0.1.0",
    output_path: Annotated[
        Path | None,
        typer.Option(
            "--path",
            "-p",
            help="Directory to create the plugin in (defaults to user plugin dir).",
            file_okay=False,
            dir_okay=True,
            writable=True,
            resolve_path=True,
        ),
    ] = None,
    include_tests: Annotated[
        bool,
        typer.Option(
            "--with-tests/--no-tests",
            help="Include placeholder pytest files in the scaffold.",
        ),
    ] = False,
    force: Annotated[
        bool,
        typer.Option(
            "--force/--no-force",
            help="Overwrite existing files when the directory already exists.",
        ),
    ] = False,
) -> None:
    """Generate a plugin scaffold to jump-start development."""

    console = Console()
    settings_obj = Settings.from_config()
    raw_name = plugin_name.strip()
    normalised = raw_name.lower()

    if not PLUGIN_NAME_PATTERN.match(normalised):
        raise typer.BadParameter(
            "Plugin name must start with a letter and use lowercase, digits, or underscores.",
            param_hint="plugin_name",
        )

    plugin_name = normalised

    if output_path is None:
        target_root = _select_scaffold_root(settings_obj)
    else:
        target_root = output_path

    target_root = target_root.expanduser()
    target_root.mkdir(parents=True, exist_ok=True)

    target_dir = target_root / plugin_name
    if target_dir.exists():
        has_content = any(target_dir.iterdir())
        if has_content and not force:
            console.print(
                f"[red]Directory {target_dir} already exists. Use --force to overwrite.[/red]"
            )
            raise typer.Exit(code=1)
    else:
        target_dir.mkdir(parents=True, exist_ok=True)

    try:
        files = build_plugin_scaffold(
            plugin_name=plugin_name,
            description=description,
            version=version,
            template_type=plugin_type,
            include_tests=include_tests,
        )
    except Exception as exc:  # pragma: no cover - defensive
        console.print(f"[red]Failed to build scaffold: {exc}[/red]")
        raise typer.Exit(code=1) from exc

    created: list[tuple[str, str]] = []
    for relative_path, content in files.items():
        destination = target_dir / relative_path
        destination.parent.mkdir(parents=True, exist_ok=True)
        action = "overwrote" if destination.exists() else "created"
        destination.write_text(content, encoding="utf-8")
        created.append((action, relative_path))

    console.print(
        f"[bold green]Plugin scaffold ready[/bold green] in [cyan]{target_dir}[/cyan]"
    )
    if raw_name != plugin_name:
        console.print(
            f"  • Normalised plugin name to [bold]{plugin_name}[/bold] from '{raw_name}'."
        )
    for action, relative_path in created:
        console.print(f"  • {action}: {relative_path}")
    console.print(
        "  • Update config and runtime files before enabling the plugin.",
        style="dim",
    )
    if settings_obj.plugins_disable_local_discovery:
        console.print(
            "  • Local plugin discovery is disabled. Set `plugins_disable_local_discovery = false`"
            " in your config or export `PLUGINS_DISABLE_LOCAL_DISCOVERY=false` to load filesystem"
            " plugins.",
            style="yellow",
        )
    if not settings_obj.enable_plugins:
        console.print(
            "  • Plugin system is disabled (`enable_plugins = false`). Update configuration to"
            " load plugins.",
            style="yellow",
        )