# Multi-Client Install & HTTP Transport Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extend Teukhos with a plugin-based installer system for 15+ agentic clients, add auth enforcement middleware, API key resolution with `env:` prefix, and polish the HTTP transport for remote deployment.

**Architecture:** Strategy pattern for installers — one class per client under `teukhos/installers/`, a shared `BaseInstaller` ABC, and a registry module. Auth middleware intercepts HTTP requests via Starlette middleware. API key resolution is a standalone utility in `teukhos/auth.py`.

**Tech Stack:** Python 3.11+, Pydantic v2, FastMCP 3.x, Typer, Starlette (via FastMCP), Rich

**Spec:** `docs/superpowers/specs/2026-03-15-multi-client-install-and-http-transport-design.md`

---

## File Map

### New Files

| File | Responsibility |
|------|---------------|
| `teukhos/auth.py` | `resolve_key()` utility + `AuthMiddleware` (Starlette ASGI middleware) |
| `teukhos/installers/__init__.py` | Registry: `all_installers()`, `discover_clients()`, `get_installer()` |
| `teukhos/installers/base.py` | `BaseInstaller` ABC + `InstallScope` enum + shared JSON merge helpers |
| `teukhos/installers/claude_desktop.py` | Claude Desktop installer (migrated from cli.py) |
| `teukhos/installers/claude_code.py` | Claude Code CLI installer |
| `teukhos/installers/cursor.py` | Cursor installer |
| `teukhos/installers/github_copilot.py` | GitHub Copilot / VS Code installer |
| `teukhos/installers/gemini_cli.py` | Gemini CLI installer |
| `teukhos/installers/codex.py` | OpenAI Codex CLI installer |
| `teukhos/installers/windsurf.py` | Windsurf installer |
| `teukhos/installers/cline.py` | Cline installer |
| `teukhos/installers/roo_code.py` | Roo Code installer |
| `teukhos/installers/continue_dev.py` | Continue.dev installer |
| `teukhos/installers/kiro.py` | Kiro installer |
| `teukhos/installers/auggie.py` | Auggie installer |
| `teukhos/installers/codebuddy.py` | CodeBuddy installer |
| `teukhos/installers/opencode.py` | OpenCode installer |
| `teukhos/installers/trae.py` | Trae installer |
| `tests/test_auth.py` | Tests for resolve_key() and AuthMiddleware |
| `tests/test_installers.py` | Tests for BaseInstaller, registry, and individual installers |
| `tests/test_install_cli.py` | Tests for CLI install/uninstall/clients commands |
| `examples/remote-server.yaml` | Example HTTP config with auth |

### Modified Files

| File | Changes |
|------|---------|
| `teukhos/config.py:43-46` | Add `cors_origins` field to `ServerConfig` |
| `teukhos/cli.py` | Rewrite `install` command, add `uninstall` + `clients` commands, update banner |
| `teukhos/engine.py:23-34` | Wire auth middleware into server build |
| `teukhos/__init__.py:8` | Bump version to `0.3.0` |
| `README.md` | Full documentation update |

---

## Chunk 1: Auth Foundation (auth.py + config changes)

### Task 1: API Key Resolution — `resolve_key()`

**Files:**
- Create: `teukhos/auth.py`
- Test: `tests/test_auth.py`

- [ ] **Step 1: Write the failing tests for resolve_key()**

```python
# tests/test_auth.py
"""Tests for API key resolution and auth middleware."""

import os

import pytest

from teukhos.auth import resolve_key


def test_resolve_literal_key():
    assert resolve_key("my-secret-key-123") == "my-secret-key-123"


def test_resolve_env_key(monkeypatch):
    monkeypatch.setenv("TEUKHOS_API_KEY", "secret-from-env")
    assert resolve_key("env:TEUKHOS_API_KEY") == "secret-from-env"


def test_resolve_env_custom_var(monkeypatch):
    monkeypatch.setenv("MY_CUSTOM_KEY", "custom-secret")
    assert resolve_key("env:MY_CUSTOM_KEY") == "custom-secret"


def test_resolve_env_missing_raises():
    # Ensure the var is not set
    os.environ.pop("NONEXISTENT_KEY_12345", None)
    with pytest.raises(ValueError, match="not set"):
        resolve_key("env:NONEXISTENT_KEY_12345")


def test_resolve_env_empty_raises(monkeypatch):
    monkeypatch.setenv("EMPTY_KEY", "")
    with pytest.raises(ValueError, match="empty"):
        resolve_key("env:EMPTY_KEY")


def test_resolve_empty_string():
    assert resolve_key("") == ""


def test_resolve_env_prefix_only():
    """'env:' with no var name should raise."""
    with pytest.raises(ValueError):
        resolve_key("env:")
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `python -m pytest tests/test_auth.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'teukhos.auth'`

- [ ] **Step 3: Implement resolve_key()**

```python
# teukhos/auth.py
"""API key resolution and authentication utilities."""

from __future__ import annotations

import os


def resolve_key(value: str) -> str:
    """Resolve an API key value.

    If the value starts with 'env:', read from the named environment variable.
    Otherwise, use the value as a literal key.

    Raises:
        ValueError: If env var is referenced but not set, or if env: prefix
            has no variable name.
    """
    if value.startswith("env:"):
        env_var = value[4:]
        if not env_var:
            raise ValueError("env: prefix requires a variable name (e.g., 'env:TEUKHOS_API_KEY')")
        key = os.environ.get(env_var)
        if key is None:
            raise ValueError(f"Environment variable '{env_var}' is not set")
        if not key:
            raise ValueError(f"Environment variable '{env_var}' is set but empty")
        return key
    return value
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python -m pytest tests/test_auth.py -v`
Expected: All 6 tests PASS

- [ ] **Step 5: Commit**

```bash
git add teukhos/auth.py tests/test_auth.py
git commit -m "feat: add resolve_key() for env: prefix API key resolution"
```

---

### Task 2: Auth Enforcement Middleware

**Files:**
- Modify: `teukhos/auth.py`
- Test: `tests/test_auth.py`

- [ ] **Step 1: Write failing tests for AuthMiddleware**

Append to `tests/test_auth.py`:

```python
import pytest
from starlette.applications import Starlette
from starlette.responses import PlainTextResponse
from starlette.routing import Route
from starlette.testclient import TestClient

from teukhos.auth import AuthMiddleware


def _make_app(api_keys: list[str], protected_paths: list[str] | None = None):
    """Create a minimal Starlette app with AuthMiddleware for testing."""
    async def homepage(request):
        return PlainTextResponse("ok")

    async def mcp_endpoint(request):
        return PlainTextResponse("mcp ok")

    async def health(request):
        return PlainTextResponse("healthy")

    app = Starlette(routes=[
        Route("/", homepage),
        Route("/mcp", mcp_endpoint),
        Route("/health", health),
    ])
    app.add_middleware(
        AuthMiddleware,
        api_keys=api_keys,
        protected_paths=protected_paths or ["/mcp"],
    )
    return TestClient(app)


def test_auth_middleware_valid_key():
    client = _make_app(["secret-123"])
    resp = client.get("/mcp", headers={"Authorization": "Bearer secret-123"})
    assert resp.status_code == 200
    assert resp.text == "mcp ok"


def test_auth_middleware_invalid_key():
    client = _make_app(["secret-123"])
    resp = client.get("/mcp", headers={"Authorization": "Bearer wrong-key"})
    assert resp.status_code == 401


def test_auth_middleware_missing_header():
    client = _make_app(["secret-123"])
    resp = client.get("/mcp")
    assert resp.status_code == 401


def test_auth_middleware_health_not_protected():
    client = _make_app(["secret-123"])
    resp = client.get("/health")
    assert resp.status_code == 200
    assert resp.text == "healthy"


def test_auth_middleware_unprotected_path():
    client = _make_app(["secret-123"])
    resp = client.get("/")
    assert resp.status_code == 200


def test_auth_middleware_multiple_keys():
    client = _make_app(["key-1", "key-2"])
    resp = client.get("/mcp", headers={"Authorization": "Bearer key-2"})
    assert resp.status_code == 200


def test_auth_middleware_empty_keys_allows_all():
    """When no keys configured, middleware should not block."""
    client = _make_app([])
    resp = client.get("/mcp")
    assert resp.status_code == 200
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `python -m pytest tests/test_auth.py::test_auth_middleware_valid_key -v`
Expected: FAIL — `ImportError: cannot import name 'AuthMiddleware'`

- [ ] **Step 3: Implement AuthMiddleware**

Add to `teukhos/auth.py`:

```python
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse


class AuthMiddleware(BaseHTTPMiddleware):
    """Bearer token authentication middleware.

    Only protects specified paths (default: /mcp). Health and other
    endpoints pass through. When api_keys is empty, all requests are allowed.
    """

    def __init__(self, app, api_keys: list[str], protected_paths: list[str] | None = None):
        super().__init__(app)
        self.api_keys = set(api_keys)
        self.protected_paths = set(protected_paths or ["/mcp"])

    async def dispatch(self, request: Request, call_next):
        if not self.api_keys or request.url.path not in self.protected_paths:
            return await call_next(request)

        auth_header = request.headers.get("authorization", "")
        if not auth_header.startswith("Bearer "):
            return JSONResponse(
                {"error": "Missing or invalid Authorization header"},
                status_code=401,
            )

        token = auth_header[7:]  # Strip "Bearer "
        if token not in self.api_keys:
            return JSONResponse(
                {"error": "Invalid API key"},
                status_code=401,
            )

        return await call_next(request)
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python -m pytest tests/test_auth.py -v`
Expected: All 13 tests PASS

- [ ] **Step 5: Commit**

```bash
git add teukhos/auth.py tests/test_auth.py
git commit -m "feat: add AuthMiddleware for Bearer token validation on HTTP transport"
```

---

### Task 3: Config Model Update + Wire Auth into Engine

**Files:**
- Modify: `teukhos/config.py:43-46`
- Modify: `teukhos/engine.py:23-34`
- Test: `tests/test_auth.py` (append integration test)

- [ ] **Step 1: Add cors_origins to ServerConfig**

In `teukhos/config.py`, change `ServerConfig`:

```python
class ServerConfig(BaseModel):
    transport: TransportType = TransportType.stdio
    host: str = "127.0.0.1"
    port: int = 8765
    cors_origins: list[str] | None = None
```

- [ ] **Step 2: Run existing tests to verify no regression**

Run: `python -m pytest tests/ -v`
Expected: All 37 existing tests PASS

- [ ] **Step 3: Write failing test for auth wiring in engine**

Append to `tests/test_auth.py`:

```python
from teukhos.config import ForgeConfig, ForgeInfo, AuthConfig, AuthMode, ServerConfig, TransportType
from teukhos.engine import build_server


@pytest.mark.asyncio
async def test_build_server_with_auth_returns_resolved_keys(monkeypatch):
    """build_server should return resolved auth keys alongside the server."""
    monkeypatch.setenv("TEST_KEY", "test-secret")
    config = ForgeConfig(
        forge=ForgeInfo(name="test-auth"),
        auth=AuthConfig(mode=AuthMode.api_key, api_keys=["env:TEST_KEY"]),
        tools=[],
    )
    result = build_server(config)
    assert result.resolved_auth_keys == ["test-secret"]
    assert result.mcp is not None


@pytest.mark.asyncio
async def test_build_server_no_auth_returns_empty_keys():
    config = ForgeConfig(
        forge=ForgeInfo(name="test-no-auth"),
        tools=[],
    )
    result = build_server(config)
    assert result.resolved_auth_keys == []
```

- [ ] **Step 4: Run the test to verify it fails**

Run: `python -m pytest tests/test_auth.py::test_build_server_with_auth_returns_resolved_keys -v`
Expected: FAIL — `build_server` doesn't return a `ServerBundle` yet

- [ ] **Step 5: Create ServerBundle and update build_server()**

In `teukhos/engine.py`, add a return dataclass and update `build_server()`:

```python
from dataclasses import dataclass, field


@dataclass
class ServerBundle:
    """Result of building a server — the FastMCP instance + resolved config."""
    mcp: FastMCP
    resolved_auth_keys: list[str] = field(default_factory=list)
    cors_origins: list[str] | None = None


def build_server(config: ForgeConfig) -> ServerBundle:
    """Build a FastMCP server from a ForgeConfig."""
    mcp = FastMCP(config.forge.name)

    for tool_config in config.tools:
        adapter = _create_adapter(tool_config)
        if adapter is None:
            continue
        handler = _build_handler(tool_config, adapter, tool_config.output)
        mcp.tool(name=tool_config.name, description=tool_config.description)(handler)

    # Resolve auth keys
    resolved_keys: list[str] = []
    if config.auth.mode == AuthMode.api_key and config.auth.api_keys:
        from teukhos.auth import resolve_key
        resolved_keys = [resolve_key(k) for k in config.auth.api_keys]

    return ServerBundle(
        mcp=mcp,
        resolved_auth_keys=resolved_keys,
        cors_origins=config.server.cors_origins,
    )
```

Add the import at the top of `engine.py`:
```python
from teukhos.config import ArgConfig, ArgType, AuthMode, ForgeConfig, OutputConfig, ToolConfig
```

- [ ] **Step 6: Update existing engine tests to use ServerBundle**

Update `tests/test_engine.py` — anywhere that calls `build_server()` and uses the result as a `FastMCP`, change to `result.mcp`. For example:
```python
# Before:
server = build_server(config)
tool = await server.get_tool("echo")
# After:
bundle = build_server(config)
tool = await bundle.mcp.get_tool("echo")
```

- [ ] **Step 7: Run tests to verify they pass**

Run: `python -m pytest tests/test_auth.py tests/test_engine.py -v`
Expected: All PASS

- [ ] **Step 8: Wire middleware in cli.py serve command**

In `teukhos/cli.py`, update the `serve` function. Replace the section that calls `build_server` and `mcp.run`:

```python
    from teukhos.engine import build_server

    bundle = build_server(forge_config)
    mcp = bundle.mcp

    if forge_config.server.transport == TransportType.http:
        from starlette.middleware import Middleware

        middlewares: list[Middleware] = []

        # Wire auth middleware if keys are configured
        if bundle.resolved_auth_keys:
            from teukhos.auth import AuthMiddleware
            middlewares.append(
                Middleware(AuthMiddleware, api_keys=bundle.resolved_auth_keys)
            )

        # Wire CORS middleware if configured
        if bundle.cors_origins:
            from starlette.middleware.cors import CORSMiddleware
            middlewares.append(
                Middleware(
                    CORSMiddleware,
                    allow_origins=bundle.cors_origins,
                    allow_methods=["*"],
                    allow_headers=["*"],
                )
            )

        mcp.run(
            transport="streamable-http",
            host=forge_config.server.host,
            port=forge_config.server.port,
        )
    else:
        mcp.run(transport="stdio")
```

> **Important:** The exact method of injecting Starlette middleware into FastMCP must be verified against FastMCP 3.x's API during implementation. Check the FastMCP source for:
> - Constructor kwargs like `middleware=`
> - `mcp.settings.middleware` list
> - Direct access to the Starlette app via `mcp._app` or similar
>
> The middleware list is built cleanly above; the only question is how to pass it to FastMCP. Adjust the wiring accordingly.

- [ ] **Step 9: Update integration tests for ServerBundle**

In `tests/test_integration.py`, update any calls to `build_server()` to use `result.mcp`:
```python
# Before:
server = build_server(config)
tool = await server.get_tool("git_log")
# After:
bundle = build_server(config)
tool = await bundle.mcp.get_tool("git_log")
```

- [ ] **Step 10: Run all tests**

Run: `python -m pytest tests/ -v`
Expected: All PASS

- [ ] **Step 11: Commit**

```bash
git add teukhos/config.py teukhos/engine.py teukhos/cli.py tests/test_auth.py tests/test_engine.py tests/test_integration.py
git commit -m "feat: wire auth and CORS middleware into HTTP transport via ServerBundle"
```

---

### Task 4: Improved HTTP Startup Banner

**Files:**
- Modify: `teukhos/cli.py:265-284`

- [ ] **Step 1: Update _print_banner() for HTTP details**

Replace `_print_banner` in `teukhos/cli.py`:

```python
def _print_banner(config: object) -> None:
    """Print a startup banner with server info."""
    from teukhos.config import ForgeConfig

    assert isinstance(config, ForgeConfig)
    tool_names = [t.name for t in config.tools]

    banner = Table.grid(padding=1)
    banner.add_row("[bold cyan]Teukhos[/]", f"v{__version__}")
    banner.add_row("[bold]Server:[/]", config.forge.name)
    banner.add_row("[bold]Transport:[/]", config.server.transport.value)
    if config.server.transport == TransportType.http:
        host = config.server.host
        port = config.server.port
        banner.add_row("[bold]Endpoint:[/]", f"http://{host}:{port}/mcp")
        banner.add_row("[bold]Health:[/]", f"http://{host}:{port}/health")
    banner.add_row("[bold]Tools:[/]", ", ".join(tool_names) or "(none)")

    # Auth info
    auth_mode = config.auth.mode.value
    if auth_mode == "api_key":
        key_count = len(config.auth.api_keys)
        banner.add_row("[bold]Auth:[/]", f"api_key ({key_count} key(s) configured)")
    else:
        banner.add_row("[bold]Auth:[/]", auth_mode)

    # Connection hint for HTTP
    if config.server.transport == TransportType.http:
        banner.add_row("", "")
        banner.add_row("[bold]Connect:[/]", f"teukhos install --url http://HOST:{port}/mcp")

    console.print(Panel(banner, title="[bold]Teukhos MCP Server[/]", border_style="cyan"))
```

- [ ] **Step 2: Run existing tests**

Run: `python -m pytest tests/ -v`
Expected: All PASS (banner is not unit-tested, but shouldn't break anything)

- [ ] **Step 3: Commit**

```bash
git add teukhos/cli.py
git commit -m "feat: improve HTTP startup banner with endpoint, health, and connect info"
```

---

## Chunk 2: Installer Plugin System (base + registry + first installers)

### Task 5: BaseInstaller + InstallScope + JSON Helpers

**Files:**
- Create: `teukhos/installers/__init__.py`
- Create: `teukhos/installers/base.py`
- Test: `tests/test_installers.py`

- [ ] **Step 1: Write failing tests for BaseInstaller and helpers**

```python
# tests/test_installers.py
"""Tests for the installer plugin system."""

import json
import tempfile
from pathlib import Path

import pytest

from teukhos.installers.base import (
    BaseInstaller,
    InstallScope,
    atomic_write_json,
    merge_mcp_entry,
    read_json_config,
)


def test_install_scope_values():
    assert InstallScope.global_.value == "global"
    assert InstallScope.project.value == "project"


def test_read_json_config_existing():
    with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
        json.dump({"mcpServers": {"existing": {}}}, f)
        path = Path(f.name)
    result = read_json_config(path)
    assert result == {"mcpServers": {"existing": {}}}


def test_read_json_config_missing():
    result = read_json_config(Path("/nonexistent/config.json"))
    assert result == {}


def test_read_json_config_malformed():
    with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
        f.write("{invalid json")
        path = Path(f.name)
    with pytest.raises(json.JSONDecodeError):
        read_json_config(path)


def test_atomic_write_json(tmp_path):
    target = tmp_path / "subdir" / "config.json"
    data = {"mcpServers": {"test": {"command": "echo"}}}
    atomic_write_json(target, data)
    assert target.exists()
    assert json.loads(target.read_text()) == data


def test_atomic_write_json_creates_backup(tmp_path):
    target = tmp_path / "config.json"
    target.write_text(json.dumps({"old": True}))
    atomic_write_json(target, {"new": True})
    backup = tmp_path / "config.json.teukhos-backup"
    assert backup.exists()
    assert json.loads(backup.read_text()) == {"old": True}


def test_merge_mcp_entry():
    existing = {"mcpServers": {"other-server": {"command": "other"}}}
    entry = {"command": "teukhos", "args": ["serve", "config.yaml"]}
    result = merge_mcp_entry(existing, "teukhos-test", entry, key="mcpServers")
    assert result["mcpServers"]["teukhos-test"] == entry
    assert result["mcpServers"]["other-server"] == {"command": "other"}


def test_merge_mcp_entry_creates_key():
    existing = {}
    entry = {"command": "teukhos"}
    result = merge_mcp_entry(existing, "teukhos-test", entry, key="mcpServers")
    assert result == {"mcpServers": {"teukhos-test": {"command": "teukhos"}}}


def test_base_installer_is_abstract():
    with pytest.raises(TypeError):
        BaseInstaller()
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `python -m pytest tests/test_installers.py -v`
Expected: FAIL — `ModuleNotFoundError`

- [ ] **Step 3: Implement base.py**

```python
# teukhos/installers/base.py
"""Base installer interface and shared utilities."""

from __future__ import annotations

import json
import os
import shutil
import tempfile
from abc import ABC, abstractmethod
from enum import Enum
from pathlib import Path


class InstallScope(Enum):
    global_ = "global"
    project = "project"


class BaseInstaller(ABC):
    """Abstract base for all client installers."""

    name: str
    slug: str
    supported_scopes: list[InstallScope]

    def __init__(self, cwd: Path | None = None):
        self.cwd = cwd or Path.cwd()

    @abstractmethod
    def detect(self) -> bool:
        """Return True if this client is installed on the system."""

    @abstractmethod
    def config_path(self, scope: InstallScope) -> Path:
        """Return config path for the given scope.

        If scope is project but not supported, returns global path.
        Callers are responsible for printing the scope fallback info message.
        """

    def _effective_scope(self, scope: InstallScope) -> InstallScope:
        """Return the effective scope, falling back to global if needed."""
        if scope == InstallScope.project and InstallScope.project not in self.supported_scopes:
            return InstallScope.global_
        return scope

    @abstractmethod
    def install_stdio(self, server_name: str, teukhos_config_path: Path,
                      scope: InstallScope = InstallScope.global_) -> None:
        """Register as stdio MCP server."""

    @abstractmethod
    def install_http(self, server_name: str, url: str, api_key: str | None,
                     scope: InstallScope = InstallScope.global_) -> None:
        """Register as HTTP MCP server."""

    @abstractmethod
    def uninstall(self, server_name: str,
                  scope: InstallScope = InstallScope.global_) -> None:
        """Remove registration from client config."""


def read_json_config(path: Path) -> dict:
    """Read a JSON config file. Returns {} if file doesn't exist.

    Raises json.JSONDecodeError if file exists but contains invalid JSON.
    """
    if not path.exists():
        return {}
    return json.loads(path.read_text(encoding="utf-8"))


def atomic_write_json(path: Path, data: dict) -> None:
    """Write JSON to a file atomically with backup.

    Creates parent directories if needed. If the file already exists,
    a backup is written to <path>.teukhos-backup before replacing.
    """
    path.parent.mkdir(parents=True, exist_ok=True)

    if path.exists():
        backup = path.with_suffix(path.suffix + ".teukhos-backup")
        shutil.copy2(path, backup)

    fd, tmp_path = tempfile.mkstemp(
        dir=path.parent, suffix=".tmp", prefix=path.stem
    )
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
            f.write("\n")
        os.replace(tmp_path, path)
    except BaseException:
        os.unlink(tmp_path)
        raise


def merge_mcp_entry(config: dict, server_name: str, entry: dict,
                    key: str = "mcpServers") -> dict:
    """Merge an MCP server entry into an existing config dict.

    Creates the top-level key if it doesn't exist.
    """
    if key not in config:
        config[key] = {}
    config[key][server_name] = entry
    return config


def remove_mcp_entry(config: dict, server_name: str,
                     key: str = "mcpServers") -> dict:
    """Remove an MCP server entry from a config dict."""
    if key in config:
        config[key].pop(server_name, None)
    return config


class JsonMcpInstaller(BaseInstaller):
    """Concrete base for clients that use a JSON file with mcpServers/servers key.

    Most MCP clients follow this pattern. Subclasses only need to set class
    attributes and implement config_path(). All install/uninstall logic is shared.
    """

    config_key: str = "mcpServers"       # Top-level JSON key ("mcpServers" or "servers")
    supports_env_substitution: bool = True  # Can the client resolve ${VAR} in config?
    stdio_needs_type_field: bool = False    # Family C (VS Code) needs "type": "stdio"

    _config_path_override: dict[InstallScope, Path] | None = None

    def detect(self) -> bool:
        return self.config_path(InstallScope.global_).parent.exists()

    def install_stdio(self, server_name: str, teukhos_config_path: Path,
                      scope: InstallScope = InstallScope.global_) -> None:
        teukhos_bin = shutil.which("teukhos") or "teukhos"
        effective = self._effective_scope(scope)
        path = self.config_path(effective)
        config = read_json_config(path)
        entry: dict = {
            "command": teukhos_bin,
            "args": ["serve", str(teukhos_config_path)],
        }
        if self.stdio_needs_type_field:
            entry["type"] = "stdio"
        merge_mcp_entry(config, server_name, entry, key=self.config_key)
        atomic_write_json(path, config)

    def install_http(self, server_name: str, url: str, api_key: str | None,
                     scope: InstallScope = InstallScope.global_) -> None:
        effective = self._effective_scope(scope)
        path = self.config_path(effective)
        config = read_json_config(path)
        entry: dict = {"url": url}
        if self.stdio_needs_type_field:
            entry["type"] = "http"
        if api_key:
            if api_key.startswith("env:") and self.supports_env_substitution:
                env_var = api_key[4:]
                entry["headers"] = {"Authorization": f"Bearer ${{{env_var}}}"}
            elif api_key.startswith("env:"):
                # Client doesn't support env vars — resolve and warn
                from teukhos.auth import resolve_key
                try:
                    resolved = resolve_key(api_key)
                    entry["headers"] = {"Authorization": f"Bearer {resolved}"}
                except ValueError:
                    entry["headers"] = {"Authorization": f"Bearer {api_key}"}
            else:
                entry["headers"] = {"Authorization": f"Bearer {api_key}"}
        merge_mcp_entry(config, server_name, entry, key=self.config_key)
        atomic_write_json(path, config)

    def uninstall(self, server_name: str,
                  scope: InstallScope = InstallScope.global_) -> None:
        effective = self._effective_scope(scope)
        path = self.config_path(effective)
        config = read_json_config(path)
        remove_mcp_entry(config, server_name, key=self.config_key)
        atomic_write_json(path, config)
```

- [ ] **Step 4: Create `teukhos/installers/__init__.py`**

```python
# teukhos/installers/__init__.py
"""Installer plugin registry."""

from __future__ import annotations

from teukhos.installers.base import BaseInstaller, InstallScope


def _load_installers() -> list[BaseInstaller]:
    """Import and instantiate all installer classes."""
    installers: list[BaseInstaller] = []
    # Import each installer module; each defines a single class
    from teukhos.installers.claude_desktop import ClaudeDesktopInstaller
    from teukhos.installers.claude_code import ClaudeCodeInstaller
    from teukhos.installers.cursor import CursorInstaller
    from teukhos.installers.github_copilot import GitHubCopilotInstaller
    from teukhos.installers.gemini_cli import GeminiCLIInstaller
    from teukhos.installers.codex import CodexInstaller
    from teukhos.installers.windsurf import WindsurfInstaller
    from teukhos.installers.cline import ClineInstaller
    from teukhos.installers.roo_code import RooCodeInstaller
    from teukhos.installers.continue_dev import ContinueDevInstaller
    from teukhos.installers.kiro import KiroInstaller
    from teukhos.installers.auggie import AuggieInstaller
    from teukhos.installers.codebuddy import CodeBuddyInstaller
    from teukhos.installers.opencode import OpenCodeInstaller
    from teukhos.installers.trae import TraeInstaller

    for cls in [
        ClaudeDesktopInstaller, ClaudeCodeInstaller, CursorInstaller,
        GitHubCopilotInstaller, GeminiCLIInstaller, CodexInstaller,
        WindsurfInstaller, ClineInstaller, RooCodeInstaller,
        ContinueDevInstaller, KiroInstaller, AuggieInstaller,
        CodeBuddyInstaller, OpenCodeInstaller, TraeInstaller,
    ]:
        installers.append(cls())
    return installers


def all_installers() -> list[BaseInstaller]:
    """Return all known client installers."""
    return _load_installers()


def discover_clients() -> list[BaseInstaller]:
    """Return only installers whose client is detected on the system."""
    return [i for i in _load_installers() if i.detect()]


def get_installer(slug: str) -> BaseInstaller:
    """Look up an installer by its slug.

    Raises ValueError if the slug is unknown.
    """
    for installer in _load_installers():
        if installer.slug == slug:
            return installer
    known = ", ".join(i.slug for i in _load_installers())
    raise ValueError(f"Unknown client '{slug}'. Known clients: {known}")


__all__ = [
    "BaseInstaller", "InstallScope",
    "all_installers", "discover_clients", "get_installer",
]
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `python -m pytest tests/test_installers.py -v`

> **Note:** This will fail because the individual installer modules don't exist yet. That's expected. The base.py and helpers tests should pass if run in isolation:

Run: `python -m pytest tests/test_installers.py -k "not abstract" -v`
Expected: Helper tests PASS

- [ ] **Step 6: Commit**

```bash
git add teukhos/installers/__init__.py teukhos/installers/base.py tests/test_installers.py
git commit -m "feat: add BaseInstaller, InstallScope, and JSON merge utilities"
```

---

### Task 6: Claude Desktop Installer (migrate from cli.py)

**Files:**
- Create: `teukhos/installers/claude_desktop.py`
- Test: `tests/test_installers.py` (append)

- [ ] **Step 1: Write failing tests**

Append to `tests/test_installers.py`:

```python
from teukhos.installers.claude_desktop import ClaudeDesktopInstaller


def test_claude_desktop_slug():
    installer = ClaudeDesktopInstaller()
    assert installer.slug == "claude-desktop"
    assert installer.name == "Claude Desktop"


def test_claude_desktop_no_project_scope():
    installer = ClaudeDesktopInstaller()
    assert InstallScope.project not in installer.supported_scopes


def test_claude_desktop_install_stdio(tmp_path):
    config_file = tmp_path / "claude_desktop_config.json"
    installer = ClaudeDesktopInstaller()
    installer._config_path_override = {InstallScope.global_: config_file}

    installer.install_stdio("teukhos-test", Path("/path/to/config.yaml"))

    data = json.loads(config_file.read_text())
    assert "teukhos-test" in data["mcpServers"]
    entry = data["mcpServers"]["teukhos-test"]
    assert entry["args"] == ["serve", "/path/to/config.yaml"]


def test_claude_desktop_install_http(tmp_path):
    config_file = tmp_path / "claude_desktop_config.json"
    installer = ClaudeDesktopInstaller()
    installer._config_path_override = {InstallScope.global_: config_file}

    installer.install_http("teukhos-test", "http://localhost:8765/mcp", "env:TEUKHOS_API_KEY")

    data = json.loads(config_file.read_text())
    entry = data["mcpServers"]["teukhos-test"]
    assert entry["url"] == "http://localhost:8765/mcp"


def test_claude_desktop_uninstall(tmp_path):
    config_file = tmp_path / "claude_desktop_config.json"
    config_file.write_text(json.dumps({
        "mcpServers": {"teukhos-test": {"command": "teukhos"}, "other": {"command": "other"}}
    }))
    installer = ClaudeDesktopInstaller()
    installer._config_path_override = {InstallScope.global_: config_file}

    installer.uninstall("teukhos-test")

    data = json.loads(config_file.read_text())
    assert "teukhos-test" not in data["mcpServers"]
    assert "other" in data["mcpServers"]
```

- [ ] **Step 2: Run tests to verify they fail**

Run: `python -m pytest tests/test_installers.py::test_claude_desktop_slug -v`
Expected: FAIL — `ModuleNotFoundError`

- [ ] **Step 3: Implement ClaudeDesktopInstaller**

```python
# teukhos/installers/claude_desktop.py
"""Installer for Claude Desktop."""

from __future__ import annotations

import platform
from pathlib import Path

from teukhos.installers.base import InstallScope, JsonMcpInstaller


class ClaudeDesktopInstaller(JsonMcpInstaller):
    name = "Claude Desktop"
    slug = "claude-desktop"
    supported_scopes = [InstallScope.global_]
    supports_env_substitution = False  # Claude Desktop doesn't resolve ${VAR}

    def config_path(self, scope: InstallScope) -> Path:
        if self._config_path_override and scope in self._config_path_override:
            return self._config_path_override[scope]
        system = platform.system()
        if system == "Darwin":
            return (
                Path.home() / "Library" / "Application Support"
                / "Claude" / "claude_desktop_config.json"
            )
        elif system == "Windows":
            return (
                Path.home() / "AppData" / "Roaming"
                / "Claude" / "claude_desktop_config.json"
            )
        return Path.home() / ".config" / "claude" / "claude_desktop_config.json"
```

- [ ] **Step 4: Run tests to verify they pass**

Run: `python -m pytest tests/test_installers.py -k "claude_desktop" -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add teukhos/installers/claude_desktop.py tests/test_installers.py
git commit -m "feat: add Claude Desktop installer (migrated from cli.py)"
```

---

### Task 7: Claude Code Installer

**Files:**
- Create: `teukhos/installers/claude_code.py`
- Test: `tests/test_installers.py` (append)

- [ ] **Step 1: Write failing tests**

Append to `tests/test_installers.py`:

```python
from teukhos.installers.claude_code import ClaudeCodeInstaller


def test_claude_code_slug():
    installer = ClaudeCodeInstaller()
    assert installer.slug == "claude-code"
    assert InstallScope.project in installer.supported_scopes


def test_claude_code_install_stdio_global(tmp_path):
    config_file = tmp_path / ".claude.json"
    installer = ClaudeCodeInstaller()
    installer._config_path_override = {InstallScope.global_: config_file}

    installer.install_stdio("teukhos-test", Path("/path/to/config.yaml"))

    data = json.loads(config_file.read_text())
    assert "teukhos-test" in data["mcpServers"]


def test_claude_code_install_stdio_project(tmp_path):
    config_file = tmp_path / ".claude" / "settings.json"
    installer = ClaudeCodeInstaller(cwd=tmp_path)
    installer._config_path_override = {InstallScope.project: config_file}

    installer.install_stdio(
        "teukhos-test", Path("/path/to/config.yaml"), scope=InstallScope.project
    )

    data = json.loads(config_file.read_text())
    assert "teukhos-test" in data["mcpServers"]


def test_claude_code_install_http(tmp_path):
    config_file = tmp_path / ".claude.json"
    installer = ClaudeCodeInstaller()
    installer._config_path_override = {InstallScope.global_: config_file}

    installer.install_http("teukhos-test", "http://host:8765/mcp", "env:TEUKHOS_API_KEY")

    data = json.loads(config_file.read_text())
    entry = data["mcpServers"]["teukhos-test"]
    assert entry["url"] == "http://host:8765/mcp"
    # Claude Code supports env var substitution
    assert "${TEUKHOS_API_KEY}" in entry["headers"]["Authorization"]
```

- [ ] **Step 2: Run test to verify it fails**

Run: `python -m pytest tests/test_installers.py::test_claude_code_slug -v`
Expected: FAIL

- [ ] **Step 3: Implement ClaudeCodeInstaller**

```python
# teukhos/installers/claude_code.py
"""Installer for Claude Code CLI."""

from __future__ import annotations

from pathlib import Path

from teukhos.installers.base import InstallScope, JsonMcpInstaller


class ClaudeCodeInstaller(JsonMcpInstaller):
    name = "Claude Code"
    slug = "claude-code"
    supported_scopes = [InstallScope.global_, InstallScope.project]

    def config_path(self, scope: InstallScope) -> Path:
        if self._config_path_override and scope in self._config_path_override:
            return self._config_path_override[scope]
        effective = self._effective_scope(scope)
        if effective == InstallScope.project:
            return self.cwd / ".claude" / "settings.json"
        return Path.home() / ".claude.json"
```

- [ ] **Step 4: Run tests**

Run: `python -m pytest tests/test_installers.py -k "claude_code" -v`
Expected: All PASS

- [ ] **Step 5: Commit**

```bash
git add teukhos/installers/claude_code.py tests/test_installers.py
git commit -m "feat: add Claude Code installer with global/project scope"
```

---

### Task 8: Cursor Installer

**Files:**
- Create: `teukhos/installers/cursor.py`
- Test: `tests/test_installers.py` (append)

- [ ] **Step 1: Write failing tests**

Append to `tests/test_installers.py`:

```python
from teukhos.installers.cursor import CursorInstaller


def test_cursor_slug():
    installer = CursorInstaller()
    assert installer.slug == "cursor"
    assert InstallScope.project in installer.supported_scopes


def test_cursor_install_stdio(tmp_path):
    config_file = tmp_path / "mcp.json"
    installer = CursorInstaller()
    installer._config_path_override = {InstallScope.global_: config_file}

    installer.install_stdio("teukhos-test", Path("/path/to/config.yaml"))

    data = json.loads(config_file.read_text())
    assert "teukhos-test" in data["mcpServers"]
```

- [ ] **Step 2: Run test to verify failure, then implement**

The Cursor installer follows the same Family A/B pattern as Claude Code but with paths `~/.cursor/mcp.json` (global) and `.cursor/mcp.json` (project). The implementation is structurally identical to Claude Code with different paths.

```python
# teukhos/installers/cursor.py
"""Installer for Cursor."""

from __future__ import annotations

from pathlib import Path

from teukhos.installers.base import InstallScope, JsonMcpInstaller


class CursorInstaller(JsonMcpInstaller):
    name = "Cursor"
    slug = "cursor"
    supported_scopes = [InstallScope.global_, InstallScope.project]

    def config_path(self, scope: InstallScope) -> Path:
        if self._config_path_override and scope in self._config_path_override:
            return self._config_path_override[scope]
        effective = self._effective_scope(scope)
        if effective == InstallScope.project:
            return self.cwd / ".cursor" / "mcp.json"
        return Path.home() / ".cursor" / "mcp.json"
```

- [ ] **Step 3: Run tests and commit**

Run: `python -m pytest tests/test_installers.py -k "cursor" -v`

```bash
git add teukhos/installers/cursor.py tests/test_installers.py
git commit -m "feat: add Cursor installer with global/project scope"
```

---

### Task 9: GitHub Copilot / VS Code Installer (Family C)

**Files:**
- Create: `teukhos/installers/github_copilot.py`
- Test: `tests/test_installers.py` (append)

- [ ] **Step 1: Write failing tests**

Append to `tests/test_installers.py`:

```python
from teukhos.installers.github_copilot import GitHubCopilotInstaller


def test_github_copilot_slug():
    installer = GitHubCopilotInstaller()
    assert installer.slug == "github-copilot"


def test_github_copilot_install_stdio_uses_servers_key(tmp_path):
    """GitHub Copilot uses 'servers' not 'mcpServers'."""
    config_file = tmp_path / "mcp.json"
    installer = GitHubCopilotInstaller()
    installer._config_path_override = {InstallScope.global_: config_file}

    installer.install_stdio("teukhos-test", Path("/path/to/config.yaml"))

    data = json.loads(config_file.read_text())
    assert "servers" in data
    entry = data["servers"]["teukhos-test"]
    assert entry["type"] == "stdio"
    assert entry["command"] is not None
```

- [ ] **Step 2: Implement**

```python
# teukhos/installers/github_copilot.py
"""Installer for GitHub Copilot / VS Code."""

from __future__ import annotations

from pathlib import Path

from teukhos.installers.base import InstallScope, JsonMcpInstaller


class GitHubCopilotInstaller(JsonMcpInstaller):
    name = "GitHub Copilot"
    slug = "github-copilot"
    supported_scopes = [InstallScope.global_, InstallScope.project]
    config_key = "servers"              # VS Code uses "servers" not "mcpServers"
    stdio_needs_type_field = True       # Family C: needs "type": "stdio"

    def config_path(self, scope: InstallScope) -> Path:
        if self._config_path_override and scope in self._config_path_override:
            return self._config_path_override[scope]
        effective = self._effective_scope(scope)
        if effective == InstallScope.project:
            return self.cwd / ".vscode" / "mcp.json"
        return Path.home() / ".vscode" / "mcp.json"
```

- [ ] **Step 3: Run tests and commit**

Run: `python -m pytest tests/test_installers.py -k "github_copilot" -v`

```bash
git add teukhos/installers/github_copilot.py tests/test_installers.py
git commit -m "feat: add GitHub Copilot/VS Code installer (Family C format)"
```

---

### Task 10: Remaining Tier 1 Installers (Gemini CLI, Codex)

**Files:**
- Create: `teukhos/installers/gemini_cli.py`
- Create: `teukhos/installers/codex.py`
- Test: `tests/test_installers.py` (append)

These follow Family A/B patterns with different paths:
- Gemini CLI: `~/.gemini/settings.json`, global only, key `mcpServers`
- Codex: `~/.codex/config.json`, global only, key `mcpServers`

- [ ] **Step 1: Write tests for both**
- [ ] **Step 2: Implement both (same pattern as Claude Desktop, different paths)**
- [ ] **Step 3: Run tests and commit**

```bash
git add teukhos/installers/gemini_cli.py teukhos/installers/codex.py tests/test_installers.py
git commit -m "feat: add Gemini CLI and Codex installers"
```

---

### Task 11: Tier 2 Installers (Windsurf, Cline, Roo Code, Continue.dev, Kiro, Auggie, CodeBuddy, OpenCode, Trae)

**Files:**
- Create: 9 installer files under `teukhos/installers/`
- Test: `tests/test_installers.py` (append minimal tests per installer)

All Tier 2 installers (except Cline) subclass `JsonMcpInstaller` and only need `config_path()` + class attributes. Each is ~15 lines.

- [ ] **Step 1: Implement 8 standard Tier 2 installers**

Each follows this template (example: Windsurf):
```python
# teukhos/installers/windsurf.py
from teukhos.installers.base import InstallScope, JsonMcpInstaller
from pathlib import Path

class WindsurfInstaller(JsonMcpInstaller):
    name = "Windsurf"
    slug = "windsurf"
    supported_scopes = [InstallScope.global_, InstallScope.project]

    def config_path(self, scope: InstallScope) -> Path:
        if self._config_path_override and scope in self._config_path_override:
            return self._config_path_override[scope]
        effective = self._effective_scope(scope)
        if effective == InstallScope.project:
            return self.cwd / ".windsurf" / "mcp.json"
        return Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
```

Config paths per client:
- **Roo Code**: global `~/.roo-code/mcp.json`, project `.roo-code/mcp.json`
- **Continue.dev**: global `~/.continue/config.json`, project `.continue/config.json`
- **Kiro**: global `~/.kiro/settings.json`, project `.kiro/mcp.json`
- **Auggie**: global `~/.auggie/mcp.json`, no project
- **CodeBuddy**: global `~/.codebuddy/mcp.json`, no project
- **OpenCode**: global `~/.opencode/config.json`, no project
- **Trae**: global `~/.trae/mcp.json`, project `.trae/mcp.json`

- [ ] **Step 2: Implement Cline installer (Family D — special case)**

Cline stores MCP config nested inside VS Code's `settings.json` under `"cline.mcpServers"`. This requires a custom `install_stdio`/`install_http`/`uninstall` that merges into a nested path:

```python
# teukhos/installers/cline.py
"""Installer for Cline (VS Code extension)."""

from __future__ import annotations

import shutil
from pathlib import Path

from teukhos.installers.base import (
    BaseInstaller,
    InstallScope,
    atomic_write_json,
    read_json_config,
)


class ClineInstaller(BaseInstaller):
    name = "Cline"
    slug = "cline"
    supported_scopes = [InstallScope.global_, InstallScope.project]

    _config_path_override: dict[InstallScope, Path] | None = None

    def detect(self) -> bool:
        # Check for Cline extension in VS Code extensions dir
        extensions_dir = Path.home() / ".vscode" / "extensions"
        if not extensions_dir.exists():
            return False
        return any(d.name.startswith("saoudrizwan.claude-dev") for d in extensions_dir.iterdir() if d.is_dir())

    def config_path(self, scope: InstallScope) -> Path:
        if self._config_path_override and scope in self._config_path_override:
            return self._config_path_override[scope]
        effective = self._effective_scope(scope)
        if effective == InstallScope.project:
            return self.cwd / ".vscode" / "settings.json"
        return Path.home() / ".vscode" / "settings.json"  # Platform-specific in practice

    def _merge_entry(self, path: Path, server_name: str, entry: dict) -> None:
        config = read_json_config(path)
        if "cline.mcpServers" not in config:
            config["cline.mcpServers"] = {}
        config["cline.mcpServers"][server_name] = entry
        atomic_write_json(path, config)

    def install_stdio(self, server_name: str, teukhos_config_path: Path,
                      scope: InstallScope = InstallScope.global_) -> None:
        teukhos_bin = shutil.which("teukhos") or "teukhos"
        path = self.config_path(self._effective_scope(scope))
        entry = {"command": teukhos_bin, "args": ["serve", str(teukhos_config_path)]}
        self._merge_entry(path, server_name, entry)

    def install_http(self, server_name: str, url: str, api_key: str | None,
                     scope: InstallScope = InstallScope.global_) -> None:
        path = self.config_path(self._effective_scope(scope))
        entry: dict = {"url": url}
        if api_key:
            if api_key.startswith("env:"):
                env_var = api_key[4:]
                entry["headers"] = {"Authorization": f"Bearer ${{{env_var}}}"}
            else:
                entry["headers"] = {"Authorization": f"Bearer {api_key}"}
        self._merge_entry(path, server_name, entry)

    def uninstall(self, server_name: str,
                  scope: InstallScope = InstallScope.global_) -> None:
        path = self.config_path(self._effective_scope(scope))
        config = read_json_config(path)
        if "cline.mcpServers" in config:
            config["cline.mcpServers"].pop(server_name, None)
        atomic_write_json(path, config)
```

- [ ] **Step 2: Write a test for each confirming slug, name, and basic install_stdio**
- [ ] **Step 3: Run all installer tests**

Run: `python -m pytest tests/test_installers.py -v`

- [ ] **Step 4: Commit**

```bash
git add teukhos/installers/*.py tests/test_installers.py
git commit -m "feat: add Tier 2 installers (Windsurf, Cline, Roo Code, Continue, Kiro, Auggie, CodeBuddy, OpenCode, Trae)"
```

---

### Task 12: Registry Tests

**Files:**
- Test: `tests/test_installers.py` (append)

- [ ] **Step 1: Write registry tests**

Append to `tests/test_installers.py`:

```python
from teukhos.installers import all_installers, get_installer


def test_all_installers_returns_list():
    installers = all_installers()
    assert len(installers) >= 15
    slugs = [i.slug for i in installers]
    assert "claude-desktop" in slugs
    assert "cursor" in slugs
    assert "github-copilot" in slugs


def test_get_installer_known():
    installer = get_installer("cursor")
    assert installer.slug == "cursor"
    assert installer.name == "Cursor"


def test_get_installer_unknown():
    with pytest.raises(ValueError, match="Unknown client"):
        get_installer("nonexistent-client")
```

- [ ] **Step 2: Run tests**

Run: `python -m pytest tests/test_installers.py -k "registry or all_installers or get_installer" -v`
Expected: All PASS

- [ ] **Step 3: Commit**

```bash
git add tests/test_installers.py
git commit -m "test: add registry tests for all_installers and get_installer"
```

---

## Chunk 3: CLI Commands (install, uninstall, clients)

### Task 13: Rewrite `teukhos install` Command

**Files:**
- Modify: `teukhos/cli.py:187-253`

- [ ] **Step 1: Rewrite the install command**

Replace the existing `install` function in `cli.py` with:

```python
@app.command()
def install(
    config: Annotated[
        Path, typer.Argument(help="Path to teukhos.yaml config file")
    ] = Path(DEFAULT_CONFIG),
    client: Annotated[
        Optional[str], typer.Option("--client", "-c", help="Target client slug (e.g., cursor, claude-code)")
    ] = None,
    all_clients: Annotated[
        bool, typer.Option("--all", help="Install for all detected clients")
    ] = False,
    project: Annotated[
        bool, typer.Option("--project", help="Use project-level config instead of global")
    ] = False,
    url: Annotated[
        Optional[str], typer.Option("--url", help="Remote server URL for HTTP config")
    ] = None,
    key: Annotated[
        str, typer.Option("--key", help="API key for HTTP mode")
    ] = "env:TEUKHOS_API_KEY",
) -> None:
    """Register this MCP server with agentic clients."""
    from teukhos.installers import all_installers, discover_clients, get_installer
    from teukhos.installers.base import InstallScope

    config_path = _resolve_config(config).resolve()
    if not config_path.exists():
        console.print(f"[bold red]Error:[/] Config file not found: {config_path}")
        raise typer.Exit(1)

    try:
        forge_config = load_config(config_path)
    except Exception as e:
        console.print(f"[bold red]Error:[/] {e}")
        raise typer.Exit(1)

    server_name = f"teukhos-{forge_config.forge.name}"
    scope = InstallScope.project if project else InstallScope.global_

    # Determine which installers to use
    if client:
        try:
            installers = [get_installer(client)]
        except ValueError as e:
            console.print(f"[bold red]Error:[/] {e}")
            raise typer.Exit(1)
    elif all_clients:
        installers = discover_clients()
        if not installers:
            console.print("[yellow]No supported clients detected on this system.[/]")
            raise typer.Exit(0)
    else:
        # Auto-detect and show table
        detected = discover_clients()
        if not detected:
            console.print("[yellow]No supported clients detected.[/] Use --client to specify one.")
            raise typer.Exit(0)

        table = Table(title="Detected MCP Clients")
        table.add_column("#", style="dim")
        table.add_column("Client", style="cyan")
        table.add_column("Slug")
        table.add_column("Config Path")
        for i, inst in enumerate(detected, 1):
            table.add_row(str(i), inst.name, inst.slug, str(inst.config_path(scope)))
        console.print(table)
        console.print("\nUse [bold]--client SLUG[/] or [bold]--all[/] to install.")
        raise typer.Exit(0)

    # Execute install
    successes = []
    failures = []
    for inst in installers:
        try:
            # Scope fallback message
            if project and InstallScope.project not in inst.supported_scopes:
                console.print(
                    f"[dim]ℹ {inst.name} does not support project-level config; "
                    f"installing globally.[/]"
                )

            if url:
                inst.install_http(server_name, url, key, scope)
            else:
                inst.install_stdio(server_name, config_path, scope)
            successes.append(inst.name)
        except Exception as e:
            failures.append((inst.name, str(e)))

    for name in successes:
        console.print(f"[bold green]✓[/] Installed '{server_name}' in {name}")
    for name, error in failures:
        console.print(f"[bold red]✗[/] Failed for {name}: {error}")
```

- [ ] **Step 2: Run existing tests to check for regressions**

Run: `python -m pytest tests/ -v`
Expected: All existing tests PASS

- [ ] **Step 3: Commit**

```bash
git add teukhos/cli.py
git commit -m "feat: rewrite install command with multi-client support, --url, --project"
```

---

### Task 14: Add `teukhos uninstall` Command

**Files:**
- Modify: `teukhos/cli.py`

- [ ] **Step 1: Add the uninstall command**

Add to `cli.py` after the install command:

```python
@app.command()
def uninstall(
    config: Annotated[
        Path, typer.Argument(help="Path to teukhos.yaml config file")
    ] = Path(DEFAULT_CONFIG),
    client: Annotated[
        Optional[str], typer.Option("--client", "-c", help="Target client slug")
    ] = None,
    all_clients: Annotated[
        bool, typer.Option("--all", help="Remove from all detected clients")
    ] = False,
    project: Annotated[
        bool, typer.Option("--project", help="Target project-level config")
    ] = False,
) -> None:
    """Remove MCP server registration from clients."""
    from teukhos.installers import discover_clients, get_installer
    from teukhos.installers.base import InstallScope

    config_path = _resolve_config(config).resolve()
    try:
        forge_config = load_config(config_path)
    except Exception as e:
        console.print(f"[bold red]Error:[/] {e}")
        raise typer.Exit(1)

    server_name = f"teukhos-{forge_config.forge.name}"
    scope = InstallScope.project if project else InstallScope.global_

    if client:
        try:
            installers = [get_installer(client)]
        except ValueError as e:
            console.print(f"[bold red]Error:[/] {e}")
            raise typer.Exit(1)
    elif all_clients:
        installers = discover_clients()
    else:
        console.print("Specify [bold]--client SLUG[/] or [bold]--all[/].")
        raise typer.Exit(1)

    for inst in installers:
        try:
            inst.uninstall(server_name, scope)
            console.print(f"[bold green]✓[/] Removed '{server_name}' from {inst.name}")
        except Exception as e:
            console.print(f"[bold red]✗[/] Failed for {inst.name}: {e}")
```

- [ ] **Step 2: Commit**

```bash
git add teukhos/cli.py
git commit -m "feat: add teukhos uninstall command"
```

---

### Task 15: Add `teukhos clients` Command

**Files:**
- Modify: `teukhos/cli.py`

- [ ] **Step 1: Add the clients command**

```python
@app.command()
def clients() -> None:
    """List all known MCP clients and whether they're detected."""
    from teukhos.installers import all_installers
    from teukhos.installers.base import InstallScope

    table = Table(title="Known MCP Clients")
    table.add_column("Client", style="cyan")
    table.add_column("Slug")
    table.add_column("Detected")
    table.add_column("Scopes")
    table.add_column("Config Path")

    for inst in all_installers():
        detected = "✓" if inst.detect() else "✗"
        scopes = ", ".join(s.value for s in inst.supported_scopes)
        config = str(inst.config_path(InstallScope.global_))
        table.add_row(inst.name, inst.slug, detected, scopes, config)

    console.print(table)
```

- [ ] **Step 2: Commit**

```bash
git add teukhos/cli.py
git commit -m "feat: add teukhos clients command to list all known MCP clients"
```

---

## Chunk 4: Examples, Documentation, Version Bump

### Task 16: Remote Server Example Config

**Files:**
- Create: `examples/remote-server.yaml`

- [ ] **Step 1: Create the example**

```yaml
# examples/remote-server.yaml
# Example: Expose git tools as an HTTP MCP server with API key auth.
#
# Start:   teukhos serve examples/remote-server.yaml
# Connect: teukhos install --url http://HOST:8765/mcp --client cursor
#
# Set the API key:
#   export TEUKHOS_API_KEY="your-secret-key-here"

forge:
  name: remote-git-tools
  description: Git tools exposed over HTTP for remote access

server:
  transport: http
  host: 0.0.0.0
  port: 8765
  cors_origins: ["*"]

auth:
  mode: api_key
  api_keys:
    - "env:TEUKHOS_API_KEY"

tools:
  - name: git_log
    description: "Show recent git commits"
    adapter: cli
    cli:
      command: git
      subcommand: [log, --oneline]
    args:
      - name: count
        type: integer
        flag: "-n"
        default: 10
    output:
      type: stdout

  - name: git_status
    description: "Show working tree status"
    adapter: cli
    cli:
      command: git
      subcommand: [status, --short]
    output:
      type: stdout
```

- [ ] **Step 2: Commit**

```bash
git add examples/remote-server.yaml
git commit -m "docs: add remote server example config with HTTP transport and auth"
```

---

### Task 17: Update README.md

**Files:**
- Modify: `README.md`

- [ ] **Step 1: Add these new sections to README.md**

The README needs these additions/updates:
1. **Quick Start** — mention `teukhos install --client cursor` alongside Claude Desktop
2. **Supported Clients** — new section with table of all 15 clients, slugs, scopes
3. **CLI Reference** — update `teukhos install` docs, add `teukhos uninstall`, `teukhos clients`
4. **Remote Server** — new section covering HTTP transport, `--url` flag, API key setup
5. **Authentication** — new section on `env:` prefix convention
6. **Deployment Guide** — local and remote deployment, reverse proxy examples

> **Implementation note:** Read the existing README first, then surgically add sections. Do not rewrite the entire file. Keep the existing structure and tone.

- [ ] **Step 2: Commit**

```bash
git add README.md
git commit -m "docs: update README with multi-client install, HTTP transport, and auth docs"
```

---

### Task 18: Version Bump to 0.3.0

**Files:**
- Modify: `teukhos/__init__.py:8`

- [ ] **Step 1: Bump version**

Change in `teukhos/__init__.py`:
```python
__version__ = "0.3.0"
```

- [ ] **Step 2: Run all tests**

Run: `python -m pytest tests/ -v`
Expected: ALL tests PASS (existing + new)

- [ ] **Step 3: Commit**

```bash
git add teukhos/__init__.py
git commit -m "chore: bump version to 0.3.0"
```

---

### Task 19: Final Integration Verification

- [ ] **Step 1: Run the full test suite**

Run: `python -m pytest tests/ -v`
Expected: All tests PASS

- [ ] **Step 2: Run teukhos clients to verify detection works**

Run: `python -m teukhos clients`
Expected: Table of all 15 clients with detection status

- [ ] **Step 3: Run teukhos validate on examples**

Run: `python -m teukhos validate examples/git-tools.yaml && python -m teukhos validate examples/remote-server.yaml`
Expected: Both valid

- [ ] **Step 4: Test install dry-run (auto-detect mode)**

Run: `python -m teukhos install examples/git-tools.yaml`
Expected: Shows detected clients table, no actual install (no --client or --all)

- [ ] **Step 5: Final commit if any fixes were needed**

```bash
git add -A
git commit -m "fix: final integration fixes for v0.3.0"
```
