feat(plugins): Stage 2.1 — plugin framework and AI tool-use
Introduces a standalone plugin system where every integration lives as an independent Python script in ~/.pyra/plugins/, not hardcoded in core. Plugin framework (src/pyra/plugins/): - base.py: Tool dataclass, PyraPlugin Protocol, BasePlugin helper - loader.py: importlib-based discovery; one bad plugin never crashes pyra - registry.py: singleton aggregating tools, slash commands, system prompts - executor.py: approval gate — scans args, prompts y/N, scans result, logs - install.py: copies bundled_plugins/ to ~/.pyra/plugins/ on install Chat integration: - AI tool-use loop (litellm function calling, up to 10 iterations) - Plugin system prompt additions injected per session - Plugin slash commands merged with static commands CLI additions: - pyra plugin list/install/enable/disable/setup - pyra daemon start/stop/status/restart/install/uninstall (stubs for 2.4) Config: PluginConfig + DaemonConfig added to PyraConfig (backwards-compatible) Bootstrap: ~/.pyra/plugins/ and ~/.pyra/logs/ created on startup Security: tool args and results always injection-scanned; plugin dirs validated with assert_safe_path() before loading (symlink protection) Tests: 37 new tests (loader, registry, executor, plugin isolation security) 161 total, all passing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Callable, Coroutine
|
||||
|
||||
from pyra.plugins.base import PyraPlugin, Tool
|
||||
from pyra.plugins.loader import _log_error, load_plugins
|
||||
from pyra.vault.reader import get_key
|
||||
|
||||
|
||||
class PluginRegistry:
|
||||
_instance: PluginRegistry | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._plugins: dict[str, PyraPlugin] = {}
|
||||
|
||||
@classmethod
|
||||
def instance(cls) -> PluginRegistry:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def reset(cls) -> None:
|
||||
"""Reset singleton — for tests only."""
|
||||
cls._instance = None
|
||||
|
||||
def load_all(self, plugins_dir: Path, enabled_names: list[str]) -> None:
|
||||
all_plugins = load_plugins(plugins_dir)
|
||||
self._plugins = {}
|
||||
for plugin in all_plugins:
|
||||
if plugin.name in enabled_names:
|
||||
try:
|
||||
plugin.on_load(get_key)
|
||||
self._plugins[plugin.name] = plugin
|
||||
except Exception as exc:
|
||||
_log_error(plugin.name, exc)
|
||||
|
||||
def get_active_plugins(self) -> list[PyraPlugin]:
|
||||
return list(self._plugins.values())
|
||||
|
||||
def get_all_tools(self) -> list[Tool]:
|
||||
tools: list[Tool] = []
|
||||
for plugin in self._plugins.values():
|
||||
try:
|
||||
tools.extend(plugin.tools())
|
||||
except Exception:
|
||||
pass
|
||||
return tools
|
||||
|
||||
def get_slash_commands(self) -> dict[str, Callable[[], None]]:
|
||||
cmds: dict[str, Callable[[], None]] = {}
|
||||
for plugin in self._plugins.values():
|
||||
try:
|
||||
cmds.update(plugin.slash_commands())
|
||||
except Exception:
|
||||
pass
|
||||
return cmds
|
||||
|
||||
def get_system_prompt_additions(self) -> str:
|
||||
parts: list[str] = []
|
||||
for plugin in self._plugins.values():
|
||||
try:
|
||||
addition = plugin.system_prompt_addition()
|
||||
if addition:
|
||||
parts.append(addition.strip())
|
||||
except Exception:
|
||||
pass
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def get_daemon_tasks(self) -> list[Coroutine]: # type: ignore[type-arg]
|
||||
tasks: list[Coroutine] = [] # type: ignore[type-arg]
|
||||
for plugin in self._plugins.values():
|
||||
try:
|
||||
tasks.extend(plugin.daemon_tasks())
|
||||
except Exception:
|
||||
pass
|
||||
return tasks
|
||||
|
||||
def find_tool(self, name: str) -> Tool | None:
|
||||
for tool in self.get_all_tools():
|
||||
if tool.name == name:
|
||||
return tool
|
||||
return None
|
||||
Reference in New Issue
Block a user