# VibePod Docker Integration Specification

---

## Overview

VibePod uses the official Docker SDK for Python (`docker-py`) to manage containers.

---

## Container Naming Convention

```
vibepod-{agent}-{workspace_hash}

Examples:
  vibepod-claude-a1b2c3d4
  vibepod-gemini-e5f6g7h8
  vibepod-opencode-i9j0k1l2
```

The workspace hash is the first 8 characters of the MD5 hash of the absolute workspace path.

---

## Container Labels

All VibePod containers have these labels:

```python
labels = {
    "vibepod": "true",
    "vibepod.agent": agent_name,
    "vibepod.version": vibepod_version,
    "vibepod.workspace": workspace_path,
    "vibepod.created": iso_timestamp,
}
```

---

## Volume Mounts

### Standard Mounts

| Host | Container | Mode | Purpose |
|------|-----------|------|---------|
| `{workspace}` | `/workspace` | `rw` | Project files |
| `{config_dir}` | `/config` | `rw` | Agent credentials |
| `~/.config/vibepod/logs.db` | `/logs/logs.db` | `rw` | API logs (if proxy) |

### Agent-Specific Mounts

#### Claude

```python
volumes = {
    workspace: {"bind": "/workspace", "mode": "rw"},
    config_dir: {"bind": "/home/user/.claude", "mode": "rw"},
}
```

#### Gemini

```python
volumes = {
    workspace: {"bind": "/workspace", "mode": "rw"},
    config_dir: {"bind": "/home/user/.config/gemini", "mode": "rw"},
}
```

#### OpenCode

```python
volumes = {
    workspace: {"bind": "/workspace", "mode": "rw"},
    config_dir: {"bind": "/home/user/.config/opencode", "mode": "rw"},
}
```

---

## Environment Variables

### Passed to All Containers

```python
environment = {
    "TERM": "xterm-256color",
    "VIBEPOD": "true",
    "VIBEPOD_AGENT": agent_name,
    "VIBEPOD_WORKSPACE": "/workspace",
}
```

### Proxy Environment (when --proxy)

```python
proxy_env = {
    "HTTP_PROXY": "http://host.docker.internal:8080",
    "HTTPS_PROXY": "http://host.docker.internal:8080",
    # Agent-specific proxy vars
    "ANTHROPIC_BASE_URL": "http://host.docker.internal:8080/v1",
}
```

### API Keys (from host environment)

```python
api_key_vars = {
    "claude": "ANTHROPIC_API_KEY",
    "gemini": "GOOGLE_API_KEY",
    "opencode": "OPENAI_API_KEY",
    "devstral": "MISTRAL_API_KEY",
    "auggie": "AUGMENT_API_KEY",
}
```

---

## Docker Client Implementation

### `src/vibepod/core/docker.py`

```python
"""Docker SDK wrapper."""

from __future__ import annotations

import hashlib
import os
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

import docker
from docker.errors import DockerException, ImageNotFound, NotFound
from docker.models.containers import Container

from vibepod import __version__
from vibepod.constants import (
    AGENTS,
    CONTAINER_PREFIX,
    DOCKER_NETWORK,
    EXIT_DOCKER_NOT_RUNNING,
    EXIT_IMAGE_NOT_FOUND,
)
from vibepod.core.config import get_config


class DockerClient:
    """Docker client wrapper for VibePod."""
    
    def __init__(self):
        """Initialize Docker client."""
        try:
            self.client = docker.from_env()
            self.client.ping()
        except DockerException as e:
            raise RuntimeError(f"Cannot connect to Docker: {e}")
    
    def _workspace_hash(self, workspace: Path) -> str:
        """Generate short hash from workspace path."""
        return hashlib.md5(str(workspace).encode()).hexdigest()[:8]
    
    def _container_name(self, agent: str, workspace: Path) -> str:
        """Generate container name."""
        return f"{CONTAINER_PREFIX}-{agent}-{self._workspace_hash(workspace)}"
    
    def _get_labels(self, agent: str, workspace: Path) -> dict[str, str]:
        """Get container labels."""
        return {
            "vibepod": "true",
            "vibepod.agent": agent,
            "vibepod.version": __version__,
            "vibepod.workspace": str(workspace),
            "vibepod.created": datetime.utcnow().isoformat(),
        }
    
    def _get_volumes(self, agent: str, workspace: Path, config_dir: Path) -> dict:
        """Get volume mounts for agent."""
        agent_info = AGENTS[agent]
        
        volumes = {
            str(workspace): {"bind": "/workspace", "mode": "rw"},
        }
        
        # Agent-specific config mount
        config_mount_paths = {
            "claude": "/home/user/.claude",
            "gemini": "/home/user/.config/gemini",
            "opencode": "/home/user/.config/opencode",
            "devstral": "/home/user/.config/devstral",
            "auggie": "/home/user/.config/auggie",
        }
        
        if agent in config_mount_paths:
            config_dir.mkdir(parents=True, exist_ok=True)
            volumes[str(config_dir)] = {
                "bind": config_mount_paths[agent],
                "mode": "rw",
            }
        
        return volumes
    
    def _get_environment(
        self,
        agent: str,
        proxy: bool = False,
        extra_env: Optional[dict] = None,
    ) -> dict[str, str]:
        """Get environment variables for container."""
        env = {
            "TERM": "xterm-256color",
            "VIBEPOD": "true",
            "VIBEPOD_AGENT": agent,
            "VIBEPOD_WORKSPACE": "/workspace",
        }
        
        # Pass through API keys
        api_key_vars = {
            "claude": ["ANTHROPIC_API_KEY"],
            "gemini": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
            "opencode": ["OPENAI_API_KEY"],
            "devstral": ["MISTRAL_API_KEY"],
            "auggie": ["AUGMENT_API_KEY"],
        }
        
        for var in api_key_vars.get(agent, []):
            if var in os.environ:
                env[var] = os.environ[var]
        
        # Proxy settings
        if proxy:
            env.update({
                "HTTP_PROXY": "http://host.docker.internal:8080",
                "HTTPS_PROXY": "http://host.docker.internal:8080",
            })
        
        # Extra environment
        if extra_env:
            env.update(extra_env)
        
        return env
    
    def ensure_network(self) -> None:
        """Ensure VibePod network exists."""
        try:
            self.client.networks.get(DOCKER_NETWORK)
        except NotFound:
            self.client.networks.create(DOCKER_NETWORK, driver="bridge")
    
    def pull_image(self, image: str) -> None:
        """Pull Docker image."""
        self.client.images.pull(image)
    
    def image_exists(self, image: str) -> bool:
        """Check if image exists locally."""
        try:
            self.client.images.get(image)
            return True
        except ImageNotFound:
            return False
    
    def run_agent(
        self,
        agent: str,
        workspace: Path,
        proxy: bool = False,
        detach: bool = False,
        env: Optional[list[str]] = None,
        headless: bool = False,
        extra_volumes: Optional[dict] = None,
    ) -> Container:
        """Run an agent container."""
        config = get_config()
        agent_config = config["agents"].get(agent, {})
        
        image = agent_config.get("image", AGENTS[agent]["image"])
        config_dir = Path(agent_config.get(
            "config_dir",
            Path.home() / ".config" / "vibepod" / "agents" / agent
        ))
        
        # Check image exists
        if not self.image_exists(image):
            if config.get("auto_pull", False):
                self.pull_image(image)
            else:
                raise ImageNotFound(f"Image not found: {image}. Run: vp update --images")
        
        # Ensure network
        self.ensure_network()
        
        # Build container config
        container_name = self._container_name(agent, workspace)
        
        # Stop existing container if running
        self.stop_agent(agent, workspace)
        
        # Parse extra env from list
        extra_env = {}
        if env:
            for e in env:
                if "=" in e:
                    key, val = e.split("=", 1)
                    extra_env[key] = val
        
        # Merge with agent config env
        extra_env = {**agent_config.get("env", {}), **extra_env}
        
        # Build volumes
        volumes = self._get_volumes(agent, workspace, config_dir)
        if extra_volumes:
            volumes.update(extra_volumes)
        
        # Add agent config volumes
        for vol in agent_config.get("volumes", []):
            if ":" in vol:
                parts = vol.split(":")
                host_path = Path(parts[0]).expanduser().resolve()
                container_path = parts[1]
                mode = parts[2] if len(parts) > 2 else "rw"
                volumes[str(host_path)] = {"bind": container_path, "mode": mode}
        
        # Run container
        container = self.client.containers.run(
            image,
            name=container_name,
            labels=self._get_labels(agent, workspace),
            environment=self._get_environment(agent, proxy, extra_env),
            volumes=volumes,
            network=DOCKER_NETWORK,
            working_dir="/workspace",
            stdin_open=not headless,
            tty=not headless,
            detach=True,
            remove=config.get("auto_remove", True),
            extra_hosts={"host.docker.internal": "host-gateway"},
        )
        
        if not detach and not headless:
            # Attach to container
            self._attach(container)
        
        return container
    
    def _attach(self, container: Container) -> None:
        """Attach to container interactively."""
        import subprocess
        subprocess.run([
            "docker", "attach", container.name
        ])
    
    def stop_agent(self, agent: str, workspace: Optional[Path] = None) -> bool:
        """Stop agent container."""
        if workspace:
            container_name = self._container_name(agent, workspace)
            try:
                container = self.client.containers.get(container_name)
                container.stop(timeout=10)
                return True
            except NotFound:
                return False
        else:
            # Stop all containers for this agent
            stopped = False
            for container in self.list_containers(agent=agent):
                container.stop(timeout=10)
                stopped = True
            return stopped
    
    def stop_all(self, force: bool = False) -> int:
        """Stop all VibePod containers."""
        count = 0
        for container in self.list_containers():
            if force:
                container.kill()
            else:
                container.stop(timeout=10)
            count += 1
        return count
    
    def list_containers(
        self,
        agent: Optional[str] = None,
        running_only: bool = False,
    ) -> list[Container]:
        """List VibePod containers."""
        filters = {"label": "vibepod=true"}
        if agent:
            filters["label"] = [f"vibepod=true", f"vibepod.agent={agent}"]
        
        if running_only:
            filters["status"] = "running"
        
        return self.client.containers.list(all=not running_only, filters=filters)
    
    def get_container(self, agent: str, workspace: Path) -> Optional[Container]:
        """Get container for agent and workspace."""
        container_name = self._container_name(agent, workspace)
        try:
            return self.client.containers.get(container_name)
        except NotFound:
            return None
    
    def exec_in_container(
        self,
        container: Container,
        command: list[str],
        workdir: Optional[str] = None,
        interactive: bool = False,
        tty: bool = False,
    ) -> tuple[int, str]:
        """Execute command in container."""
        if interactive or tty:
            import subprocess
            cmd = ["docker", "exec"]
            if interactive:
                cmd.append("-i")
            if tty:
                cmd.append("-t")
            if workdir:
                cmd.extend(["-w", workdir])
            cmd.append(container.name)
            cmd.extend(command)
            result = subprocess.run(cmd)
            return result.returncode, ""
        else:
            result = container.exec_run(command, workdir=workdir)
            return result.exit_code, result.output.decode()
    
    def get_logs(
        self,
        container: Container,
        tail: int = 100,
        follow: bool = False,
        since: Optional[datetime] = None,
    ):
        """Get container logs."""
        return container.logs(
            tail=tail,
            follow=follow,
            since=since,
            stream=follow,
        )
    
    def container_info(self, container: Container) -> dict[str, Any]:
        """Get container info."""
        return {
            "id": container.short_id,
            "name": container.name,
            "status": container.status,
            "agent": container.labels.get("vibepod.agent"),
            "workspace": container.labels.get("vibepod.workspace"),
            "created": container.labels.get("vibepod.created"),
            "image": container.image.tags[0] if container.image.tags else "unknown",
        }
```

---

## Docker Compose Support

### `docker-compose.yml` (for manual usage)

```yaml
# VibePod Docker Compose
# Usage: docker compose --profile claude up

version: "3.8"

services:
  claude:
    image: vibepod/claude:latest
    profiles: [claude]
    volumes:
      - ${WORKSPACE:-.}:/workspace
      - ${HOME}/.config/vibepod/agents/claude:/home/user/.claude
    environment:
      - ANTHROPIC_API_KEY
    stdin_open: true
    tty: true
    working_dir: /workspace
    networks:
      - vibepod
  
  gemini:
    image: vibepod/gemini:latest
    profiles: [gemini]
    volumes:
      - ${WORKSPACE:-.}:/workspace
      - ${HOME}/.config/vibepod/agents/gemini:/home/user/.config/gemini
    environment:
      - GOOGLE_API_KEY
    stdin_open: true
    tty: true
    working_dir: /workspace
    networks:
      - vibepod
  
  opencode:
    image: vibepod/opencode:latest
    profiles: [opencode]
    volumes:
      - ${WORKSPACE:-.}:/workspace
      - ${HOME}/.config/vibepod/agents/opencode:/home/user/.config/opencode
    environment:
      - OPENAI_API_KEY
    stdin_open: true
    tty: true
    working_dir: /workspace
    networks:
      - vibepod
  
  devstral:
    image: vibepod/devstral:latest
    profiles: [devstral]
    volumes:
      - ${WORKSPACE:-.}:/workspace
      - ${HOME}/.config/vibepod/agents/devstral:/home/user/.config/devstral
    environment:
      - MISTRAL_API_KEY
    stdin_open: true
    tty: true
    working_dir: /workspace
    networks:
      - vibepod
  
  auggie:
    image: vibepod/auggie:latest
    profiles: [auggie]
    volumes:
      - ${WORKSPACE:-.}:/workspace
      - ${HOME}/.config/vibepod/agents/auggie:/home/user/.config/auggie
    environment:
      - AUGMENT_API_KEY
    stdin_open: true
    tty: true
    working_dir: /workspace
    networks:
      - vibepod
  
  datasette:
    image: vibepod/datasette:latest
    profiles: [logging]
    ports:
      - "8001:8001"
    volumes:
      - ${HOME}/.config/vibepod/logs.db:/data/logs.db:ro
    networks:
      - vibepod

networks:
  vibepod:
    name: vibepod-network
```

---

## Error Handling

```python
"""Docker error handling."""

from docker.errors import (
    DockerException,
    ImageNotFound,
    NotFound,
    APIError,
    ContainerError,
)

class VibePodDockerError(Exception):
    """Base Docker error."""
    pass

class DockerNotRunningError(VibePodDockerError):
    """Docker daemon not running."""
    exit_code = 3

class ImageNotFoundError(VibePodDockerError):
    """Docker image not found."""
    exit_code = 4

class ContainerNotFoundError(VibePodDockerError):
    """Container not found."""
    exit_code = 7

def handle_docker_error(func):
    """Decorator to handle Docker errors."""
    def wrapper(*args, **kwargs):
        try:
            return func(*args, **kwargs)
        except DockerException as e:
            if "connection refused" in str(e).lower():
                raise DockerNotRunningError("Docker is not running")
            raise VibePodDockerError(str(e))
        except ImageNotFound as e:
            raise ImageNotFoundError(str(e))
        except NotFound as e:
            raise ContainerNotFoundError(str(e))
    return wrapper
```
