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:
curo1305
2026-05-17 15:35:20 +02:00
parent 30cda28ec8
commit c0c0156468
21 changed files with 1569 additions and 59 deletions
+166
View File
@@ -0,0 +1,166 @@
"""Tests for plugin discovery and loading."""
import json
from pathlib import Path
import pytest
from pyra.plugins.loader import load_plugin_by_name, load_plugins
def _make_plugin(plugins_dir: Path, name: str, plugin_code: str, manifest: dict | None = None) -> Path:
"""Helper: create a minimal plugin directory."""
plugin_dir = plugins_dir / name
plugin_dir.mkdir(parents=True)
if manifest is None:
manifest = {"name": name, "version": "0.1.0", "description": "Test plugin"}
(plugin_dir / "manifest.json").write_text(json.dumps(manifest))
(plugin_dir / "plugin.py").write_text(plugin_code)
return plugin_dir
_MINIMAL_PLUGIN = """\
from pyra.plugins.base import BasePlugin
class _Plugin(BasePlugin):
name = "test_plugin"
description = "A test plugin"
version = "0.1.0"
def get_plugin():
return _Plugin()
"""
_TOOL_PLUGIN = """\
from pyra.plugins.base import BasePlugin, Tool
class _Plugin(BasePlugin):
name = "tool_plugin"
description = "Plugin with a tool"
version = "0.1.0"
def tools(self):
return [
Tool(
name="say_hello",
description="Says hello",
parameters={"type": "object", "properties": {}},
handler=lambda: "hello",
requires_approval=False,
)
]
def get_plugin():
return _Plugin()
"""
def test_load_valid_plugin(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
_make_plugin(plugins_dir, "test_plugin", _MINIMAL_PLUGIN)
plugin = load_plugin_by_name("test_plugin", plugins_dir)
assert plugin is not None
assert plugin.name == "test_plugin"
def test_load_plugins_discovers_all(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
_make_plugin(plugins_dir, "plugin_a", _MINIMAL_PLUGIN.replace("test_plugin", "plugin_a"))
_make_plugin(plugins_dir, "plugin_b", _MINIMAL_PLUGIN.replace("test_plugin", "plugin_b"))
plugins = load_plugins(plugins_dir)
names = {p.name for p in plugins}
assert "plugin_a" in names
assert "plugin_b" in names
def test_load_plugins_empty_dir(tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
assert load_plugins(plugins_dir) == []
def test_load_plugins_missing_dir(tmp_path):
assert load_plugins(tmp_path / "nonexistent") == []
def test_missing_manifest_returns_none(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
plugin_dir = plugins_dir / "bad_plugin"
plugin_dir.mkdir()
(plugin_dir / "plugin.py").write_text(_MINIMAL_PLUGIN)
# No manifest.json
result = load_plugin_by_name("bad_plugin", plugins_dir)
assert result is None
def test_missing_plugin_py_returns_none(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
plugin_dir = plugins_dir / "bad_plugin"
plugin_dir.mkdir()
(plugin_dir / "manifest.json").write_text(json.dumps({"name": "bad_plugin", "version": "1.0.0"}))
# No plugin.py
result = load_plugin_by_name("bad_plugin", plugins_dir)
assert result is None
def test_invalid_manifest_returns_none(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
plugin_dir = plugins_dir / "bad_plugin"
plugin_dir.mkdir()
(plugin_dir / "manifest.json").write_text('{"name": "bad_plugin"}') # missing version
(plugin_dir / "plugin.py").write_text(_MINIMAL_PLUGIN)
result = load_plugin_by_name("bad_plugin", plugins_dir)
assert result is None
def test_no_get_plugin_returns_none(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
code = "# No get_plugin function here\nclass Foo: pass"
_make_plugin(plugins_dir, "no_factory", code)
result = load_plugin_by_name("no_factory", plugins_dir)
assert result is None
def test_plugin_with_syntax_error_returns_none(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
_make_plugin(plugins_dir, "broken", "def get_plugin(: INVALID SYNTAX")
result = load_plugin_by_name("broken", plugins_dir)
assert result is None
def test_one_bad_plugin_does_not_prevent_others(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
_make_plugin(plugins_dir, "good", _MINIMAL_PLUGIN.replace("test_plugin", "good"))
_make_plugin(plugins_dir, "bad", "SYNTAX ERROR !!!")
plugins = load_plugins(plugins_dir)
names = [p.name for p in plugins]
assert "good" in names
assert len(names) == 1
def test_plugin_errors_logged(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
_make_plugin(plugins_dir, "bad", "SYNTAX ERROR")
load_plugin_by_name("bad", plugins_dir)
log_file = tmp_pyra_home / "logs" / "plugin_errors.log"
assert log_file.exists()
assert "bad" in log_file.read_text()
def test_plugin_tools_accessible(tmp_pyra_home, tmp_path):
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
_make_plugin(plugins_dir, "tool_plugin", _TOOL_PLUGIN)
plugin = load_plugin_by_name("tool_plugin", plugins_dir)
assert plugin is not None
tools = plugin.tools()
assert len(tools) == 1
assert tools[0].name == "say_hello"