"""PluginManager — unified CLI command binding and lifecycle coordinator.
Provides a ``PluginManager`` class that ties together discovery, loading,
enabling/disabling, and unloading of plugins. It also exposes convenience
methods for integrating with the ClawCodex CLI and hook systems.
Architecture alignment:
- ``docs/feature_plan/06-ccb-benchmark/f-70-plugin.md`` §1.6
"""
from __future__ import annotations
import asyncio
import logging
import shutil
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from .base import BasePlugin, PluginContext
from .loader import (
PluginDiscoveryResult,
clear_loaded_plugins,
discover_all_plugins,
discover_entry_point_plugins,
discover_plugins,
ensure_plugin_dirs,
get_enabled_plugins,
get_loaded_plugin,
get_loaded_plugins,
load_plugin_from_directory,
load_plugins_from_directories,
register_plugin,
scan_plugin_directory,
toggle_plugin_enabled,
unregister_plugin,
)
from .marketplace import install_plugin as _marketplace_install
from .marketplace import uninstall_plugin as _marketplace_uninstall
from .sandbox import (
SandboxedPlugin,
SandboxConfig,
SandboxMode,
clear_sandboxes,
get_sandbox,
register_sandbox,
remove_sandbox,
start_sandbox,
stop_sandbox,
)
from .types import LoadedPlugin, PluginError, PluginManifest
logger = logging.getLogger(__name__)
@dataclass
class PluginManager:
"""Central coordinator for the plugin system.
Handles discovery, loading, lifecycle transitions, sandbox setup,
and provides a unified API for interacting with the plugin ecosystem.
Usage::
mgr = PluginManager()
mgr.discover()
mgr.load_all()
# … use plugins …
mgr.unload_all()
"""
auto_discover: bool = True
extra_dirs: list[str | Path] = field(default_factory=list)
sandbox_enabled: bool = False
sandbox_configs: dict[str, SandboxConfig] = field(default_factory=dict)
_instances: dict[str, BasePlugin] = field(default_factory=dict, repr=False)
def __post_init__(self) -> None:
if self.auto_discover:
self.discover()
def discover(self, *, recursive: bool = False) -> PluginDiscoveryResult:
"""Run all discovery strategies and register found plugins.
Args:
recursive: Whether to scan directories recursively.
Returns a ``PluginDiscoveryResult`` with all discovered plugins
and any errors encountered.
"""
result = discover_all_plugins(extra_dirs=self.extra_dirs, recursive=recursive)
logger.info(
'Discovered %d plugins (%d errors)',
len(result.plugins), len(result.errors),
)
return result
def discover_directory(self, directory: str | Path, *, recursive: bool = False) -> PluginDiscoveryResult:
"""Scan a single directory for plugins.
Args:
directory: Path to scan.
recursive: Whether to scan subdirectories recursively.
"""
if recursive:
result = scan_plugin_directory(directory, recursive=True)
else:
result = discover_plugins(directory)
for plugin in result.plugins:
register_plugin(plugin)
return result
def discover_entry_points(self) -> list[LoadedPlugin]:
"""Discover plugins registered via Python entry_points."""
plugins = discover_entry_point_plugins()
for plugin in plugins:
register_plugin(plugin)
return plugins
def load_plugin(
self,
plugin: LoadedPlugin,
tool_registry: Any = None,
command_system: Any = None,
config: dict[str, Any] | None = None,
data_dir: Path | None = None,
) -> BasePlugin | None:
"""Instantiate and load a plugin's runtime logic.
If *tool_registry* is provided the plugin's ``get_tools()`` will
be registered automatically.
Returns the instantiated ``BasePlugin`` or ``None`` on failure.
"""
try:
plugin_path = Path(plugin.path)
init_file = plugin_path / "__init__.py"
if not init_file.exists():
logger.warning("No __init__.py in %s — skipping runtime load", plugin.path)
return None
import importlib.util
spec = importlib.util.spec_from_file_location(
f"clawcodex.plugin.{plugin.name}", init_file
)
if spec is None or spec.loader is None:
logger.warning("Could not load spec for %s", plugin.name)
return None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
instances: list[BasePlugin] = []
for attr_name in dir(module):
attr = getattr(module, attr_name)
if (
isinstance(attr, type)
and issubclass(attr, BasePlugin)
and attr is not BasePlugin
):
try:
instance = attr()
except TypeError:
continue
instances.append(instance)
if not instances:
logger.warning(
"No BasePlugin subclass found in %s", plugin.path
)
return None
base_plugin = instances[0]
ctx = PluginContext(
registry=tool_registry,
command_system=command_system,
config=config or {},
data_dir=data_dir,
)
base_plugin._plugin_context = ctx
if tool_registry is not None:
for tool in base_plugin.get_tools():
try:
tool_registry.register(tool)
except Exception as exc:
logger.warning(
"Failed to register tool %s: %s",
getattr(tool, 'name', '?'), exc,
)
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None and loop.is_running():
import asyncio as _asyncio
task = _asyncio.create_task(base_plugin.on_load(ctx))
base_plugin._on_load_task = task
else:
asyncio.run(base_plugin.on_load(ctx))
self._instances[base_plugin.name] = base_plugin
logger.info("Loaded plugin: %s", base_plugin.name)
return base_plugin
except Exception as exc:
logger.error("Failed to load plugin %s: %s", plugin.name, exc)
return None
def load_all(
self,
tool_registry: Any = None,
command_system: Any = None,
) -> dict[str, BasePlugin]:
"""Load all registered enabled plugins.
Returns a dict mapping plugin name → BasePlugin instance.
"""
loaded: dict[str, BasePlugin] = {}
for plugin in get_enabled_plugins():
instance = self.load_plugin(
plugin,
tool_registry=tool_registry,
command_system=command_system,
)
if instance is not None:
loaded[instance.name] = instance
return loaded
def unload_plugin(self, name: str) -> bool:
"""Unload a single plugin by name.
Calls ``on_unload`` on the active instance, removes it from the
instance cache, and cleans up its sandbox if one exists.
Returns ``True`` if the plugin was loaded and is now unloaded.
"""
instance = self._instances.pop(name, None)
if instance is None:
return False
try:
if asyncio.iscoroutinefunction(instance.on_unload):
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop is not None and loop.is_running():
import asyncio as _asyncio
_asyncio.create_task(instance.on_unload())
else:
asyncio.run(instance.on_unload())
else:
pass
except Exception as exc:
logger.error("Error unloading plugin %s: %s", name, exc)
sandbox = get_sandbox(name)
if sandbox is not None:
stop_sandbox(sandbox)
remove_sandbox(name)
logger.info("Unloaded plugin: %s", name)
return True
def enable_plugin(self, name: str) -> bool:
"""Enable a registered plugin."""
result = toggle_plugin_enabled(name, True)
if result:
plugin = get_loaded_plugin(name)
if plugin:
self._setup_sandbox(plugin)
return result
def disable_plugin(self, name: str) -> bool:
"""Disable a registered plugin.
If the plugin is currently loaded, it will be unloaded first.
"""
self.unload_plugin(name)
return toggle_plugin_enabled(name, False)
def install_plugin(
self,
source_dir: str | Path,
plugin_name: str,
*,
target_dir: str | Path | None = None,
tool_registry: Any = None,
command_system: Any = None,
) -> LoadedPlugin:
"""Install a plugin from a source directory into the plugin directory.
Args:
source_dir: Directory containing the plugin source.
plugin_name: Name of the plugin to install.
target_dir: Target directory (defaults to first default plugin dir).
tool_registry: Optional tool registry for loading the plugin.
command_system: Optional command system for loading the plugin.
Returns:
The installed ``LoadedPlugin``.
Raises:
PluginError: If the plugin cannot be installed.
"""
if target_dir is None:
dirs = ensure_plugin_dirs()
target_dir = dirs[0]
plugin = _marketplace_install(source_dir, target_dir, plugin_name)
register_plugin(plugin)
if plugin.enabled:
self.load_plugin(
plugin,
tool_registry=tool_registry,
command_system=command_system,
)
logger.info("Installed plugin: %s → %s", plugin_name, target_dir)
return plugin
def uninstall_plugin(self, name: str, *, plugin_dir: str | Path | None = None) -> bool:
"""Uninstall and remove a plugin.
Args:
name: Plugin name to uninstall.
plugin_dir: Directory containing installed plugins (defaults
to first default plugin dir).
Returns ``True`` if the plugin was found and removed.
"""
self.unload_plugin(name)
if plugin_dir is None:
dirs = ensure_plugin_dirs()
plugin_dir = dirs[0]
removed = _marketplace_uninstall(plugin_dir, name)
if removed:
logger.info("Uninstalled plugin: %s from %s", name, plugin_dir)
return unregister_plugin(name) or removed
def unload_all(self) -> None:
"""Unload all registered plugins and clean up sandboxes."""
for name in list(self._instances.keys()):
self.unload_plugin(name)
self._instances.clear()
clear_loaded_plugins()
clear_sandboxes()
logger.info("All plugins unloaded")
def _setup_sandbox(self, plugin: LoadedPlugin) -> None:
"""Configure sandbox for a plugin if sandboxing is enabled."""
if not self.sandbox_enabled:
return
cfg = self.sandbox_configs.get(plugin.name)
if cfg is None:
cfg = SandboxConfig(mode=SandboxMode.PROCESS)
sb = register_sandbox(plugin, cfg)
start_sandbox(sb)
def get_sandbox(self, plugin_name: str) -> Any | None:
"""Get the sandbox for a plugin."""
return get_sandbox(plugin_name)
def list_plugins(self, enabled_only: bool = False) -> list[LoadedPlugin]:
"""List registered plugins."""
if enabled_only:
return get_enabled_plugins()
return get_loaded_plugins()
def get_plugin_status(self, name: str) -> dict[str, Any] | None:
"""Get detailed status for a plugin."""
plugin = get_loaded_plugin(name)
if plugin is None:
return None
instance = self._instances.get(name)
return {
"name": plugin.name,
"version": plugin.manifest.version,
"enabled": plugin.enabled,
"source": plugin.source,
"path": plugin.path,
"loaded": instance is not None,
"has_sandbox": get_sandbox(name) is not None,
}
def shutdown(self) -> None:
"""Full shutdown: unload plugins, clean sandboxes, clear registry."""
self.unload_all()
def create_manager(
auto_discover: bool = True,
extra_dirs: list[str | Path] | None = None,
sandbox_enabled: bool = False,
) -> PluginManager:
"""Convenience factory for creating a PluginManager.
Usage::
mgr = create_manager(sandbox_enabled=True)
"""
return PluginManager(
auto_discover=auto_discover,
extra_dirs=extra_dirs or [],
sandbox_enabled=sandbox_enabled,
)