diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..87d3e3c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.git +.gitignore +.idea +.vscode +.venv +.python-version +.pre-commit-config.yaml +docs +tests +examples +**/__pycache__ +**/*.pyc +**/.pytest_cache +**/.ruff_cache +**/.ty_cache +README.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..6e51acb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,18 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.13 + hooks: + - id: ruff-check + types_or: [python, pyi] + args: [--fix] + - id: ruff-format + types_or: [python, pyi] + + - repo: local + hooks: + - id: ty + name: ty check + entry: uvx ty check + language: python + types_or: [python, pyi] + pass_filenames: false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..da89a78 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +# syntax=docker/dockerfile:1.7 +# +# Two stages: a uv-based builder that resolves the venv, and a thin +# python:3.13-slim runtime with bun + the `claude` CLI baked in. +# +# Alpine is intentionally avoided: @anthropic-ai/claude-code ≥ 2.1.113 +# ships a glibc-only native binary (anthropics/claude-code#50270). +# +# This image is deliberately un-opinionated about ports and command — +# the consumer's compose file decides what to publish and how to invoke. + +# ---- Builder: uv + Python deps ---- +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder +ENV UV_LINK_MODE=copy \ + UV_COMPILE_BYTECODE=1 \ + UV_PYTHON_DOWNLOADS=never +# `git` is required at build time: the `prod` extra resolves +# raycast-api / claude-code-api from git URLs. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* +WORKDIR /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-dev --no-install-project --extra prod + +COPY . /app +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev --extra prod + +# ---- Runtime ---- +FROM python:3.13-slim AS runtime +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* + +# Bun native binary (glibc) — fast `npm install` replacement, only used +# to drop the claude CLI into the image. Not invoked at runtime. +COPY --from=oven/bun:1-slim /usr/local/bin/bun /usr/local/bin/bun +RUN ln -s /usr/local/bin/bun /usr/local/bin/bunx + +# `--trust` is required: without it bun skips the postinstall step that +# fetches claude's native binary (anthropics/claude-code#50203). +ENV BUN_INSTALL=/usr/local/bun-global \ + PATH=/usr/local/bun-global/bin:/app/.venv/bin:$PATH +RUN bun install -g --trust @anthropic-ai/claude-code \ + && claude --version + +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app /app +WORKDIR /app + +ENTRYPOINT ["python", "-m"] +CMD ["beaver_gateway"] diff --git a/examples/config.py b/examples/config.py new file mode 100644 index 0000000..91cb630 --- /dev/null +++ b/examples/config.py @@ -0,0 +1,18 @@ +# Stub user config for the Phase 0 DoD. +# +# Loader (beaver_gateway/config_loader.py) execs this file with +# ClaudeAgent, RaycastAgent, McpServer, ExposedMcp, Gateway already +# bound. The top-level `gateway = Gateway(...)` is what gets picked up. + +gateway = Gateway( # type: ignore[name-defined] # noqa: F821 + agents=[ + ClaudeAgent( # type: ignore[name-defined] # noqa: F821 + name="stub", + model="claude-sonnet-4-5", + system_prompt="You are a stub agent used to validate the Phase 0 skeleton.", + cwd="/tmp", + ), + ], + mcps=[], + frontends=[], +) diff --git a/examples/docker-compose.yml b/examples/docker-compose.yml new file mode 100644 index 0000000..caa5b8f --- /dev/null +++ b/examples/docker-compose.yml @@ -0,0 +1,20 @@ +# Minimal compose for the Phase 0 DoD: build the image, mount a +# single-file user config, watch the process print its summary line +# and exit cleanly. +# +# Real deployments add a postgres service, raycast-config.json bind, +# ~/.config/claude bind, etc. — Phase 5 lives in a separate example +# repo (see PLAN §5.1). + +services: + gateway: + build: + context: .. + dockerfile: Dockerfile + environment: + DATABASE_URL: "sqlite:///./gateway.db" + ADMIN_USER: "admin" + ADMIN_PASS: "change-me" + SESSION_SECRET: "dev-secret-change-me" + volumes: + - ./config.py:/config/config.py:ro diff --git a/pyproject.toml b/pyproject.toml index 5907d07..1b0b23e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,6 @@ name = "beaver-gateway" version = "0.1.0" description = "Agentic registry, gateway and head of operations for Beaver agent" -readme = "README.md" authors = [ { name = "h", email = "h@kotikot.com" } ] diff --git a/src/beaver_gateway/__init__.py b/src/beaver_gateway/__init__.py index 03f146b..b0e3deb 100644 --- a/src/beaver_gateway/__init__.py +++ b/src/beaver_gateway/__init__.py @@ -1,2 +1,7 @@ -def main() -> None: - print("Hello from beaver-gateway!") +"""Beaver Gateway — personal AI agent gateway.""" + +from __future__ import annotations + +from beaver_gateway.cli import main + +__all__ = ["main"] diff --git a/src/beaver_gateway/__main__.py b/src/beaver_gateway/__main__.py new file mode 100644 index 0000000..97c8e42 --- /dev/null +++ b/src/beaver_gateway/__main__.py @@ -0,0 +1,8 @@ +"""Allow ``python -m beaver_gateway``.""" + +from __future__ import annotations + +from beaver_gateway.cli import main + +if __name__ == "__main__": + main() diff --git a/src/beaver_gateway/agents/__init__.py b/src/beaver_gateway/agents/__init__.py new file mode 100644 index 0000000..7d5020b --- /dev/null +++ b/src/beaver_gateway/agents/__init__.py @@ -0,0 +1,9 @@ +"""Agent type definitions exposed to user config.""" + +from __future__ import annotations + +from beaver_gateway.agents.base import BaseAgent, ExposedMcp +from beaver_gateway.agents.claude import ClaudeAgent +from beaver_gateway.agents.raycast import RaycastAgent + +__all__ = ["BaseAgent", "ClaudeAgent", "ExposedMcp", "RaycastAgent"] diff --git a/src/beaver_gateway/agents/base.py b/src/beaver_gateway/agents/base.py new file mode 100644 index 0000000..19ef84e --- /dev/null +++ b/src/beaver_gateway/agents/base.py @@ -0,0 +1,33 @@ +"""Shared agent surface. + +``BaseAgent`` is the structural contract every backend-specific agent +honours. Subclasses add **capabilities as fields** (``ClaudeAgent.cwd``, +``RaycastAgent.streaming``) — backends dispatch on type, not on a runtime +matrix. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from pydantic import BaseModel, ConfigDict + + +@dataclass(frozen=True, slots=True) +class ExposedMcp: + """Reference to an ``McpServer`` (by name) exposed to a single agent.""" + + name: str + tools: tuple[str, ...] | None = None + + +class BaseAgent(BaseModel): + """Common fields. Concrete backends subclass and add capabilities.""" + + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + name: str + model: str + system_prompt: str + expose_mcps: tuple[ExposedMcp, ...] = () + accept_client_tools: bool = False diff --git a/src/beaver_gateway/agents/claude.py b/src/beaver_gateway/agents/claude.py new file mode 100644 index 0000000..aa7d7c9 --- /dev/null +++ b/src/beaver_gateway/agents/claude.py @@ -0,0 +1,17 @@ +"""Claude Code agent definition. + +Notice the absence of a ``streaming`` field — claude-code does not emit +token-level deltas, and that fact is encoded in the type, not in a +runtime branch. +""" + +from __future__ import annotations + +from beaver_gateway.agents.base import BaseAgent + + +class ClaudeAgent(BaseAgent): + """Agent backed by ``claude-code-api``.""" + + cwd: str + available_native_tools: tuple[str, ...] = () diff --git a/src/beaver_gateway/agents/raycast.py b/src/beaver_gateway/agents/raycast.py new file mode 100644 index 0000000..4360124 --- /dev/null +++ b/src/beaver_gateway/agents/raycast.py @@ -0,0 +1,15 @@ +"""Raycast agent definition.""" + +from __future__ import annotations + +from raycast_api import Source + +from beaver_gateway.agents.base import BaseAgent + + +class RaycastAgent(BaseAgent): + """Agent backed by ``raycast-api``.""" + + streaming: bool = True + available_native_tools: tuple[str, ...] = () + source: Source = Source.AI_CHAT diff --git a/src/beaver_gateway/cli.py b/src/beaver_gateway/cli.py new file mode 100644 index 0000000..f02d1dc --- /dev/null +++ b/src/beaver_gateway/cli.py @@ -0,0 +1,28 @@ +"""Process entrypoint. + +Phase 0.3 — load the user's ``/config/config.py`` via +``config_loader``, build registries, print the +``loaded N agents, M mcps, K frontends`` line from the Phase 0 DoD, +exit cleanly. Phase 0.4 will install uvloop and start uvicorn(s) for +the frontends and the internal MCP app. +""" + +from __future__ import annotations + +from beaver_gateway import config_loader +from beaver_gateway.core.registry import AgentRegistry, McpRegistry +from beaver_gateway.settings import Settings + + +def main() -> None: + settings = Settings() # ty: ignore[missing-argument] + + gateway = config_loader.load(settings.config_path) + + agents = AgentRegistry(gateway.agents) + mcps = McpRegistry(gateway.mcps) + + print( + f"beaver-gateway: loaded {len(agents)} agents, " + f"{len(mcps)} mcps, {len(gateway.frontends)} frontends" + ) diff --git a/src/beaver_gateway/config_loader.py b/src/beaver_gateway/config_loader.py new file mode 100644 index 0000000..87876e8 --- /dev/null +++ b/src/beaver_gateway/config_loader.py @@ -0,0 +1,99 @@ +"""Load the user's ``/config/config.py``. + +The config file is regular Python. We ``exec`` it in a namespace seeded +with the public surface the user is expected to use (``ClaudeAgent``, +``RaycastAgent``, ``McpServer``, ``ExposedMcp``, ``Gateway``). The file +must assign a top-level ``gateway = Gateway(...)``. + +Element-level validation already happens at construction time — agents +and ``McpServer`` factories are pydantic models that reject garbage. +What we *can't* validate at construction is the ``Gateway`` container +itself (deliberately a plain dataclass per PRD), so we type-check its +contents here before handing it back. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from beaver_gateway.agents.base import BaseAgent, ExposedMcp +from beaver_gateway.agents.claude import ClaudeAgent +from beaver_gateway.agents.raycast import RaycastAgent +from beaver_gateway.core.registry import Gateway +from beaver_gateway.frontends.base import Frontend +from beaver_gateway.mcp.types import HttpMcp, McpServer, PythonToolMcp, StdioMcp + +if TYPE_CHECKING: + from pathlib import Path + + +class ConfigError(Exception): + """User config file is missing, unreadable, or structurally wrong.""" + + +_PUBLIC_NAMES: dict[str, Any] = { + "ClaudeAgent": ClaudeAgent, + "RaycastAgent": RaycastAgent, + "McpServer": McpServer, + "ExposedMcp": ExposedMcp, + "Gateway": Gateway, +} + +_McpInstance = StdioMcp | HttpMcp | PythonToolMcp + + +def load(path: Path) -> Gateway: + """Execute ``path`` and return its top-level ``gateway`` object.""" + try: + source = path.read_text(encoding="utf-8") + except FileNotFoundError as exc: + msg = f"config file not found: {path}" + raise ConfigError(msg) from exc + except OSError as exc: + msg = f"could not read config file {path}: {exc}" + raise ConfigError(msg) from exc + + code = compile(source, str(path), "exec") + namespace: dict[str, Any] = {"__file__": str(path), **_PUBLIC_NAMES} + exec(code, namespace) # noqa: S102 - exec'ing user config is the feature + + try: + gw = namespace["gateway"] + except KeyError as exc: + msg = f"{path}: no top-level `gateway = Gateway(...)` found" + raise ConfigError(msg) from exc + + if not isinstance(gw, Gateway): + msg = ( + f"{path}: top-level `gateway` must be a Gateway instance, " + f"got {type(gw).__name__}" + ) + raise ConfigError(msg) + + _validate(gw, path) + return gw + + +def _validate(gw: Gateway, path: Path) -> None: + for i, a in enumerate(gw.agents): + if not isinstance(a, BaseAgent): + msg = ( + f"{path}: gateway.agents[{i}] must be a ClaudeAgent / " + f"RaycastAgent instance, got {type(a).__name__}" + ) + raise ConfigError(msg) + for i, m in enumerate(gw.mcps): + if not isinstance(m, _McpInstance): + msg = ( + f"{path}: gateway.mcps[{i}] must be built via " + f"McpServer.stdio/.http/.python_tool, " + f"got {type(m).__name__}" + ) + raise ConfigError(msg) + for i, f in enumerate(gw.frontends): + if not isinstance(f, Frontend): + msg = ( + f"{path}: gateway.frontends[{i}] must be a Frontend, " + f"got {type(f).__name__}" + ) + raise ConfigError(msg) diff --git a/src/beaver_gateway/core/__init__.py b/src/beaver_gateway/core/__init__.py new file mode 100644 index 0000000..63d3592 --- /dev/null +++ b/src/beaver_gateway/core/__init__.py @@ -0,0 +1,7 @@ +"""Cross-cutting machinery: registries, event protocol, auth, sessions.""" + +from __future__ import annotations + +from beaver_gateway.core.registry import AgentRegistry, Gateway, McpRegistry + +__all__ = ["AgentRegistry", "Gateway", "McpRegistry"] diff --git a/src/beaver_gateway/core/registry.py b/src/beaver_gateway/core/registry.py new file mode 100644 index 0000000..d45c365 --- /dev/null +++ b/src/beaver_gateway/core/registry.py @@ -0,0 +1,83 @@ +"""Agent / MCP registries + the user-facing ``Gateway`` collector. + +The user's ``/config/config.py`` ends with:: + + gateway = Gateway(agents=[...], mcps=[...], frontends=[...]) + +``cli.main`` picks that object up and builds the registries. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Iterable, Iterator + + from beaver_gateway.agents.base import BaseAgent + from beaver_gateway.frontends.base import Frontend + from beaver_gateway.mcp.types import McpServerT + + +class AgentRegistry: + """Name → agent lookup with duplicate detection.""" + + def __init__(self, agents: Iterable[BaseAgent]) -> None: + self._agents: dict[str, BaseAgent] = {} + for a in agents: + if a.name in self._agents: + msg = f"duplicate agent name: {a.name!r}" + raise ValueError(msg) + self._agents[a.name] = a + + def __getitem__(self, name: str) -> BaseAgent: + return self._agents[name] + + def get(self, name: str) -> BaseAgent | None: + return self._agents.get(name) + + def __iter__(self) -> Iterator[BaseAgent]: + return iter(self._agents.values()) + + def __len__(self) -> int: + return len(self._agents) + + def __contains__(self, name: object) -> bool: + return name in self._agents + + +class McpRegistry: + """Name → MCP server lookup with duplicate detection.""" + + def __init__(self, mcps: Iterable[McpServerT]) -> None: + self._mcps: dict[str, McpServerT] = {} + for m in mcps: + if m.name in self._mcps: + msg = f"duplicate mcp name: {m.name!r}" + raise ValueError(msg) + self._mcps[m.name] = m + + def __getitem__(self, name: str) -> McpServerT: + return self._mcps[name] + + def get(self, name: str) -> McpServerT | None: + return self._mcps.get(name) + + def __iter__(self) -> Iterator[McpServerT]: + return iter(self._mcps.values()) + + def __len__(self) -> int: + return len(self._mcps) + + def __contains__(self, name: object) -> bool: + return name in self._mcps + + +@dataclass(slots=True) +class Gateway: + """Top-level object the user assembles in ``/config/config.py``.""" + + agents: list[BaseAgent] = field(default_factory=list) + mcps: list[McpServerT] = field(default_factory=list) + frontends: list[Frontend] = field(default_factory=list) diff --git a/src/beaver_gateway/frontends/__init__.py b/src/beaver_gateway/frontends/__init__.py new file mode 100644 index 0000000..71f4f31 --- /dev/null +++ b/src/beaver_gateway/frontends/__init__.py @@ -0,0 +1,7 @@ +"""Frontend protocols and (later) concrete app implementations.""" + +from __future__ import annotations + +from beaver_gateway.frontends.base import Frontend + +__all__ = ["Frontend"] diff --git a/src/beaver_gateway/frontends/base.py b/src/beaver_gateway/frontends/base.py new file mode 100644 index 0000000..03ed04f --- /dev/null +++ b/src/beaver_gateway/frontends/base.py @@ -0,0 +1,24 @@ +"""Frontend ABC. + +A frontend is anything that listens on a port and routes inbound traffic +into the agent/MCP registries. ``configure`` is called once after the +``Gateway`` is built; ``serve`` runs the listening loop. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from beaver_gateway.core.registry import Gateway + + +class Frontend(ABC): + """Listens on a port, dispatches into the gateway.""" + + @abstractmethod + def configure(self, gateway: Gateway) -> None: ... + + @abstractmethod + async def serve(self) -> None: ... diff --git a/src/beaver_gateway/mcp/__init__.py b/src/beaver_gateway/mcp/__init__.py new file mode 100644 index 0000000..345f34e --- /dev/null +++ b/src/beaver_gateway/mcp/__init__.py @@ -0,0 +1,13 @@ +"""MCP server definitions and (later) the internal aggregator app.""" + +from __future__ import annotations + +from beaver_gateway.mcp.types import ( + HttpMcp, + McpServer, + McpServerT, + PythonToolMcp, + StdioMcp, +) + +__all__ = ["HttpMcp", "McpServer", "McpServerT", "PythonToolMcp", "StdioMcp"] diff --git a/src/beaver_gateway/mcp/types.py b/src/beaver_gateway/mcp/types.py new file mode 100644 index 0000000..5ba17d5 --- /dev/null +++ b/src/beaver_gateway/mcp/types.py @@ -0,0 +1,77 @@ +"""User-facing MCP server declarations. + +Three flavours, one factory facade (``McpServer``). The factory returns +discriminated-union members so downstream code can ``match`` on ``kind``. +""" + +from __future__ import annotations + +from collections.abc import Callable # noqa: TC003 — runtime use by pydantic +from typing import TYPE_CHECKING, Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field + +if TYPE_CHECKING: + from collections.abc import Iterable + + +class _BaseMcp(BaseModel): + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + name: str + + +class StdioMcp(_BaseMcp): + """Subprocess MCP server we spawn and connect to over stdio.""" + + kind: Literal["stdio"] = "stdio" + command: tuple[str, ...] + env: dict[str, str] | None = None + cwd: str | None = None + + +class HttpMcp(_BaseMcp): + """Remote MCP server reached over streamable HTTP.""" + + kind: Literal["http"] = "http" + url: str + auth: str | None = None + + +class PythonToolMcp(_BaseMcp): + """Bundle of Python callables exposed as one FastMCP namespace.""" + + kind: Literal["python_tool"] = "python_tool" + tools: tuple[Callable[..., object], ...] + + +McpServerT = Annotated[ + StdioMcp | HttpMcp | PythonToolMcp, Field(discriminator="kind") +] + + +class McpServer: + """Factory facade matching the PRD-documented config surface.""" + + @classmethod + def stdio( + cls, + *, + name: str, + command: Iterable[str], + env: dict[str, str] | None = None, + cwd: str | None = None, + ) -> StdioMcp: + return StdioMcp(name=name, command=tuple(command), env=env, cwd=cwd) + + @classmethod + def http( + cls, *, name: str, url: str, auth: str | None = None + ) -> HttpMcp: + return HttpMcp(name=name, url=url, auth=auth) + + @classmethod + def python_tool( + cls, *, name: str, tools: Iterable[Callable[..., object]] + ) -> PythonToolMcp: + return PythonToolMcp(name=name, tools=tuple(tools)) diff --git a/src/beaver_gateway/settings.py b/src/beaver_gateway/settings.py new file mode 100644 index 0000000..cba48e4 --- /dev/null +++ b/src/beaver_gateway/settings.py @@ -0,0 +1,31 @@ +"""Process-wide configuration loaded from environment (`.env`). + +Everything that varies between deployments (creds, paths, ports) lives here. +User-facing agent/MCP/frontend definitions live in ``/config/config.py`` +and are loaded by ``config_loader`` (Phase 0.3). +""" + +from __future__ import annotations + +from pathlib import Path + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + """Runtime environment for the gateway process.""" + + model_config = SettingsConfigDict( + env_file=".env", env_file_encoding="utf-8", extra="ignore" + ) + + database_url: str + admin_user: str + admin_pass: str + session_secret: str + + internal_mcp_port: int = 8765 + config_path: Path = Path("/config/config.py") + + raycast_bearer: str | None = None + raycast_config_path: Path = Path("/config/raycast.json")