feat: implement skeleton phase

This commit is contained in:
h
2026-05-19 14:19:15 +02:00
parent 75a23d231e
commit 221e660c5c
21 changed files with 586 additions and 3 deletions
+16
View File
@@ -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
+18
View File
@@ -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
+56
View File
@@ -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"]
+18
View File
@@ -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=[],
)
+20
View File
@@ -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
-1
View File
@@ -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" }
]
+7 -2
View File
@@ -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"]
+8
View File
@@ -0,0 +1,8 @@
"""Allow ``python -m beaver_gateway``."""
from __future__ import annotations
from beaver_gateway.cli import main
if __name__ == "__main__":
main()
+9
View File
@@ -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"]
+33
View File
@@ -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
+17
View File
@@ -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, ...] = ()
+15
View File
@@ -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
+28
View File
@@ -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"
)
+99
View File
@@ -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)
+7
View File
@@ -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"]
+83
View File
@@ -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)
+7
View File
@@ -0,0 +1,7 @@
"""Frontend protocols and (later) concrete app implementations."""
from __future__ import annotations
from beaver_gateway.frontends.base import Frontend
__all__ = ["Frontend"]
+24
View File
@@ -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: ...
+13
View File
@@ -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"]
+77
View File
@@ -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))
+31
View File
@@ -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")