# Plugin System

Cleave supports custom backend plugins, enabling you to integrate any LLM provider with the TUI.

## Overview

The plugin system allows you to:
- Add custom LLM backends without modifying Cleave's source code
- Use local inference servers (Ollama, vLLM, llama.cpp)
- Integrate proprietary or custom LLM APIs
- Share backend implementations with the community

## Quick Start

1. **Choose a template** from `docs/plugin-template/`:
   - `custom_backend.py` - Blank template with documentation
   - `anthropic_legacy.py` - Complete working example

2. **Copy and customize**:
   ```bash
   mkdir -p ~/.cleave/backends
   cp docs/plugin-template/custom_backend.py ~/.cleave/backends/my_backend.py
   # Edit my_backend.py with your implementation
   ```

3. **Configure Cleave**:
   ```bash
   cleave config set backend my-backend
   cleave config set backends.my-backend.api_key "your-key"
   cleave config set backends.my-backend.model "your-model"
   ```

4. **Launch TUI**:
   ```bash
   cleave tui
   ```

## Plugin Directory

Plugins are automatically discovered from:
```
~/.cleave/backends/
```

Each plugin is a single Python file (`.py`) that implements the `Backend` interface.

## Plugin Structure

### Minimal Plugin

```python
from cleave.tui.backends.base import Backend, BackendConfig, BackendState, Message
from collections.abc import AsyncIterator

class MinimalBackend(Backend):
    def __init__(self):
        self._state = BackendState.DISCONNECTED

    @property
    def name(self) -> str:
        return "My Backend"

    @property
    def state(self) -> BackendState:
        return self._state

    async def connect(self, config: BackendConfig) -> None:
        self._state = BackendState.CONNECTED

    async def disconnect(self) -> None:
        self._state = BackendState.DISCONNECTED

    async def query(self, prompt: str) -> AsyncIterator[Message]:
        yield Message(role="assistant", content="Hello from my backend!")

    @classmethod
    def check_auth(cls) -> tuple[bool, str]:
        return (True, "Ready")

    @classmethod
    def available_models(cls, config=None) -> list[str]:
        return ["default-model"]

# Required metadata
__plugin_name__ = "my-backend"
__plugin_version__ = "1.0.0"
__plugin_backend__ = MinimalBackend
```

### Required Metadata

Every plugin must define:

```python
__plugin_name__ = "unique-identifier"     # Kebab-case, unique across plugins
__plugin_version__ = "1.0.0"              # Semver version string
__plugin_backend__ = YourBackendClass     # Backend implementation class
```

Optional metadata:

```python
__plugin_author__ = "Your Name"           # Author name
__plugin_description__ = "Brief description of your backend"
```

## Backend Interface

Plugins implement the `Backend` abstract base class from `cleave.tui.backends.base`.

### Properties

```python
@property
def name(self) -> str:
    """Display name shown in TUI."""

@property
def state(self) -> BackendState:
    """Current connection state (DISCONNECTED, CONNECTING, CONNECTED, ERROR)."""

@property
def error(self) -> str | None:
    """Last error message, or None."""
```

### Connection Management

```python
async def connect(self, config: BackendConfig) -> None:
    """Initialize connection to your LLM service.

    Args:
        config: Configuration including model, API keys, base URL, etc.

    Raises:
        ConnectionError: If connection fails.
    """

async def disconnect(self) -> None:
    """Clean up resources. Safe to call multiple times."""
```

### Query Processing

```python
async def query(self, prompt: str) -> AsyncIterator[Message]:
    """Send prompt and stream responses.

    Args:
        prompt: User message.

    Yields:
        Message objects (use MessageType for streaming support).

    Raises:
        RuntimeError: If not connected.
    """
```

### Static Methods

```python
@classmethod
def check_auth(cls) -> tuple[bool, str]:
    """Check if backend is authenticated.

    Returns:
        (is_authenticated, status_message)
    """

@classmethod
def available_models(cls, config: BackendConfig | None = None) -> list[str]:
    """List available models.

    Args:
        config: Optional config for dynamic model discovery.

    Returns:
        List of model identifiers.
    """

@classmethod
def default_config(cls) -> BackendConfig:
    """Provide default configuration (optional).

    Returns:
        BackendConfig with sensible defaults.
    """
```

## Message Types

For streaming support, use `MessageType`:

```python
from cleave.tui.backends.base import MessageType

# Stream start
yield Message(role="assistant", content="", message_type=MessageType.STREAM_START)

# Stream chunks
yield Message(role="assistant", content="chunk", message_type=MessageType.STREAM_DELTA)

# Stream end
yield Message(role="assistant", content="full_response", message_type=MessageType.STREAM_END)

# Non-streaming (legacy)
yield Message(role="assistant", content="response", message_type=MessageType.COMPLETE)
```

## Configuration

### BackendConfig

The `BackendConfig` dataclass provides standard configuration:

```python
@dataclass
class BackendConfig:
    model: str = ""                           # Model identifier
    base_url: str | None = None               # API base URL
    api_key: str | None = None                # API key
    timeout: float = 120.0                    # Request timeout (seconds)
    max_turns: int = 50                       # Max conversation turns
    system_prompt: str | None = None          # System prompt
    allowed_tools: list[str] | None = None    # Tool filtering
    disallowed_tools: list[str] | None = None
    working_directory: str | None = None      # Working directory
```

### User Configuration

Users configure plugins via CLI:

```bash
# Select backend
cleave config set backend my-backend

# Configure backend-specific settings
cleave config set backends.my-backend.model "gpt-4"
cleave config set backends.my-backend.api_key "sk-..."
cleave config set backends.my-backend.base_url "https://api.example.com"

# View configuration
cleave config show
```

Or by editing `~/.cleave/settings.yaml`:

```yaml
backend: my-backend

backends:
  my-backend:
    model: gpt-4
    api_key: sk-...
    base_url: https://api.example.com
    timeout: 120.0
```

## Security

### API Key Management

Never hardcode API keys:

```python
# Good: Read from config or environment
api_key = config.api_key or os.getenv("MY_API_KEY")

# Bad: Hardcoded
api_key = "sk-1234567890"  # Never do this!
```

### Input Validation

Validate user input before sending to external services:

```python
async def query(self, prompt: str) -> AsyncIterator[Message]:
    if not prompt.strip():
        yield Message(role="assistant", content="Empty prompt", metadata={"error": True})
        return

    if len(prompt) > 10000:
        yield Message(role="assistant", content="Prompt too long", metadata={"error": True})
        return

    # ... process prompt
```

### Timeouts

Always use timeouts to prevent hangs:

```python
async with httpx.AsyncClient(timeout=config.timeout) as client:
    response = await client.post(...)
```

### Error Handling

Don't expose sensitive information in errors:

```python
# Bad
yield Message(content=f"Failed with key {api_key}: {error}")

# Good
yield Message(content="Authentication failed", metadata={"error": True})
```

## Plugin Discovery

### Automatic Discovery

Plugins are discovered automatically when:
- Cleave TUI starts
- `list_backends()` is called
- Backend configuration changes

### Manual Discovery

Trigger discovery programmatically:

```python
from cleave.tui.backends.plugin import get_global_registry

registry = get_global_registry()
count = registry.discover_and_register()
print(f"Discovered {count} plugins")
```

### Discovery Rules

- Only `.py` files in `~/.cleave/backends/` are scanned
- Files starting with `__` are ignored (e.g., `__init__.py`)
- Invalid plugins are logged and skipped (don't break other plugins)
- Plugin names must be unique (later plugins override earlier ones)

## Plugin Information

### List Plugins

```bash
# List all backends (built-in + plugins)
cleave config get backends

# List only plugins
cleave config get plugins
```

### Get Plugin Info

```python
from cleave.tui.backends.plugin import get_backend_info

info = get_backend_info("my-backend")
print(info["plugin_info"].version)
print(info["is_authenticated"])
```

### Check Backend Status

```python
from cleave.tui.backends.plugin import get_global_registry

registry = get_global_registry()
status = registry.get_backend_status("my-backend")

if status["exists"]:
    print(f"Backend found: {status['is_plugin']}")
    print(f"Authenticated: {status['is_authenticated']}")
    print(f"Message: {status['auth_message']}")
```

## Testing Plugins

### Manual Testing

1. Install plugin:
   ```bash
   cp my_backend.py ~/.cleave/backends/
   ```

2. Verify discovery:
   ```bash
   cleave config get backends  # Should list your plugin
   ```

3. Configure and test:
   ```bash
   cleave config set backend my-backend
   cleave config set backends.my-backend.api_key "test-key"
   cleave tui
   ```

### Unit Testing

Write tests for your plugin:

```python
import pytest
from my_backend import MyBackend

@pytest.mark.asyncio
async def test_connect():
    backend = MyBackend()
    config = BackendConfig(api_key="test")
    await backend.connect(config)
    assert backend.state == BackendState.CONNECTED

@pytest.mark.asyncio
async def test_query_streaming():
    backend = MyBackend()
    await backend.connect(BackendConfig())

    messages = []
    async for msg in backend.query("Hello"):
        messages.append(msg)

    # Verify streaming sequence
    assert messages[0].message_type == MessageType.STREAM_START
    assert messages[-1].message_type == MessageType.STREAM_END
```

### Integration Testing

Test plugin with Cleave:

```bash
# Set up test environment
export TEST_API_KEY="test-key"
cleave config set backend my-backend
cleave config set backends.my-backend.api_key "$TEST_API_KEY"

# Run TUI (will use plugin)
cleave tui

# Or run with different model
cleave tui --model "test-model"
```

## Examples

### Example 1: Ollama Local Server

```python
import httpx
from cleave.tui.backends.base import *

class OllamaBackend(Backend):
    def __init__(self):
        self._state = BackendState.DISCONNECTED
        self._client = None

    @property
    def name(self) -> str:
        return "Ollama"

    @property
    def state(self) -> BackendState:
        return self._state

    async def connect(self, config: BackendConfig) -> None:
        base_url = config.base_url or "http://localhost:11434"
        self._client = httpx.AsyncClient(base_url=base_url, timeout=config.timeout)
        self._state = BackendState.CONNECTED

    async def disconnect(self) -> None:
        if self._client:
            await self._client.aclose()
        self._state = BackendState.DISCONNECTED

    async def query(self, prompt: str) -> AsyncIterator[Message]:
        yield Message(role="assistant", content="", message_type=MessageType.STREAM_START)

        full_response = ""
        async with self._client.stream(
            "POST",
            "/api/generate",
            json={"model": self._config.model or "llama3.2", "prompt": prompt},
        ) as response:
            async for line in response.aiter_lines():
                data = json.loads(line)
                token = data.get("response", "")
                full_response += token
                yield Message(role="assistant", content=token, message_type=MessageType.STREAM_DELTA)

        yield Message(role="assistant", content=full_response, message_type=MessageType.STREAM_END)

    @classmethod
    def check_auth(cls) -> tuple[bool, str]:
        # Check if Ollama server is running
        try:
            with httpx.Client() as client:
                response = client.get("http://localhost:11434/api/tags", timeout=2.0)
                if response.status_code == 200:
                    return (True, "Ollama server running")
        except:
            pass
        return (False, "Ollama server not running (install: https://ollama.ai)")

    @classmethod
    def available_models(cls, config=None) -> list[str]:
        try:
            with httpx.Client() as client:
                response = client.get("http://localhost:11434/api/tags")
                data = response.json()
                return [m["name"] for m in data.get("models", [])]
        except:
            return ["llama3.2", "codellama", "mistral"]

__plugin_name__ = "ollama"
__plugin_version__ = "1.0.0"
__plugin_backend__ = OllamaBackend
```

### Example 2: Generic REST API

```python
import httpx
from cleave.tui.backends.base import *

class GenericAPIBackend(Backend):
    def __init__(self):
        self._state = BackendState.DISCONNECTED
        self._client = None
        self._config = None

    @property
    def name(self) -> str:
        return "Generic API"

    @property
    def state(self) -> BackendState:
        return self._state

    async def connect(self, config: BackendConfig) -> None:
        if not config.base_url:
            raise ConnectionError("base_url required in config")

        self._config = config
        self._client = httpx.AsyncClient(
            base_url=config.base_url,
            headers={"Authorization": f"Bearer {config.api_key}"},
            timeout=config.timeout,
        )
        self._state = BackendState.CONNECTED

    async def disconnect(self) -> None:
        if self._client:
            await self._client.aclose()
        self._state = BackendState.DISCONNECTED

    async def query(self, prompt: str) -> AsyncIterator[Message]:
        try:
            response = await self._client.post(
                "/v1/completions",
                json={
                    "model": self._config.model,
                    "prompt": prompt,
                    "max_tokens": 1000,
                },
            )
            response.raise_for_status()
            data = response.json()

            yield Message(
                role="assistant",
                content=data.get("completion", ""),
                message_type=MessageType.COMPLETE,
            )
        except Exception as e:
            yield Message(
                role="assistant",
                content=f"Error: {e}",
                metadata={"error": True},
            )

    @classmethod
    def check_auth(cls) -> tuple[bool, str]:
        # Require explicit configuration
        return (False, "Configure base_url and api_key")

    @classmethod
    def available_models(cls, config=None) -> list[str]:
        return ["default"]

__plugin_name__ = "generic-api"
__plugin_version__ = "1.0.0"
__plugin_backend__ = GenericAPIBackend
```

## Troubleshooting

### Plugin Not Discovered

**Problem**: Plugin file exists but isn't listed.

**Solutions**:
1. Check file location: `ls -la ~/.cleave/backends/`
2. Verify metadata is set: `grep "__plugin_name__" ~/.cleave/backends/your_plugin.py`
3. Check for syntax errors: `python3 ~/.cleave/backends/your_plugin.py`
4. Check logs: `cleave tui --debug`

### Import Errors

**Problem**: `ModuleNotFoundError` or `ImportError`.

**Solutions**:
1. Install dependencies: `pip install httpx openai`
2. Check Python version: `python3 --version` (requires 3.11+)
3. Ensure Cleave imports: `pip install styrene-cleave[full]`

### Connection Failures

**Problem**: Backend shows ERROR state.

**Solutions**:
1. Check API key: `echo $YOUR_API_KEY`
2. Verify base URL: `curl https://your-api.com/health`
3. Check firewall/network: `ping api.example.com`
4. Review error message: Look at `backend.error` property

### Streaming Not Working

**Problem**: Messages don't stream, appear all at once.

**Solutions**:
1. Use correct message types: `STREAM_START`, `STREAM_DELTA`, `STREAM_END`
2. Yield messages incrementally (don't accumulate then yield all)
3. Check API supports streaming
4. Verify async iteration: `async for chunk in stream:`

## Best Practices

1. **Start simple** - Get basic query working before adding streaming
2. **Handle errors gracefully** - Yield error messages, don't crash
3. **Use timeouts** - Prevent hangs with `timeout` parameter
4. **Document configuration** - Add comments explaining required settings
5. **Test locally** - Verify plugin works before sharing
6. **Version carefully** - Use semver for backward compatibility
7. **Secure API keys** - Use environment variables, not hardcoded values

## Resources

- [Plugin Template](plugin-template/README.md) - Complete guide to creating plugins
- [Backend API Reference](backend-api.md) - Detailed interface documentation
- [Example Plugins](https://github.com/styrene-lab/cleave-plugins) - Community plugins
- [Discord Community](https://discord.gg/styrene-lab) - Get help and share plugins

## Contributing

Share your plugin with the community:

1. Publish to GitHub
2. Add to [awesome-cleave-plugins](https://github.com/styrene-lab/awesome-cleave-plugins)
3. Share on Discord
4. Write a blog post or tutorial

We'd love to feature your plugin in our official plugin directory!
