diff --git a/.claude/settings.json b/.claude/settings.json index 87fcb3f..4b2f3db 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -65,6 +65,7 @@ "Bash(git show *)", "Bash(git branch*)", "Bash(git add *)", + "Bash(git commit *)", "Bash(git checkout -b *)", "Bash(git switch -c *)", "Bash(gh pr view*)", @@ -126,7 +127,6 @@ "Bash(docker *)", "Bash(kubectl *)", "Bash(git push *)", - "Bash(git commit *)", "Bash(git reset *)", "Bash(git rebase *)", "Bash(git merge *)" diff --git a/src/app/exceptions.py b/src/app/exceptions.py new file mode 100644 index 0000000..ab35cb3 --- /dev/null +++ b/src/app/exceptions.py @@ -0,0 +1,2 @@ +class CatalogueError(Exception): + pass diff --git a/src/app/main.py b/src/app/main.py index 79ab2d2..02f8e6b 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -10,6 +10,8 @@ from fastapi.templating import Jinja2Templates from app.core.config import settings +from app.exceptions import CatalogueError +from app.routes.agents import router as agents_router logger = logging.getLogger(__name__) @@ -39,6 +41,16 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.mount("/static", StaticFiles(directory=_UI_DIR), name="static") templates = Jinja2Templates(directory=_UI_DIR) +app.include_router(agents_router, prefix="/api") + + +@app.exception_handler(CatalogueError) +async def catalogue_error_handler( + request: Request, exc: CatalogueError +) -> JSONResponse: + logger.exception("Catalogue error: %s", exc) + return JSONResponse(status_code=500, content={"detail": "Configuration error"}) + # --Health-- @app.get("/health/live", response_model=None, status_code=200) diff --git a/src/app/models/agent.py b/src/app/models/agent.py new file mode 100644 index 0000000..9e14a58 --- /dev/null +++ b/src/app/models/agent.py @@ -0,0 +1,21 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class AgentOrSkill(BaseModel): + model_config = ConfigDict(extra="forbid") + + type: Literal["agent", "skill"] + name: str + role: str + description: str = "" + icon: str + connects_to: list[str] = [] + + +class AgentCatalogue(BaseModel): + model_config = ConfigDict(extra="forbid") + + agents: list[AgentOrSkill] + skills: list[AgentOrSkill] diff --git a/src/app/routes/agents.py b/src/app/routes/agents.py new file mode 100644 index 0000000..fb6be82 --- /dev/null +++ b/src/app/routes/agents.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends + +from app.models.agent import AgentCatalogue +from app.services.agent_catalogue import get_catalogue + +router = APIRouter() + + +@router.get("/agents", response_model=AgentCatalogue, status_code=200) +async def list_agents( + catalogue: AgentCatalogue = Depends(get_catalogue), +) -> AgentCatalogue: + """Return the full agent and skill catalogue.""" + return catalogue diff --git a/src/app/services/__init__.py b/src/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/services/agent_catalogue.py b/src/app/services/agent_catalogue.py new file mode 100644 index 0000000..1a29c65 --- /dev/null +++ b/src/app/services/agent_catalogue.py @@ -0,0 +1,32 @@ +import asyncio +import json +from pathlib import Path + +from pydantic import ValidationError + +from app.exceptions import CatalogueError +from app.models.agent import AgentCatalogue + +_AGENTS_JSON: Path = Path(__file__).resolve().parent.parent / "ui" / "agents.json" + +_catalogue: AgentCatalogue | None = None + + +async def _load() -> AgentCatalogue: + try: + raw = await asyncio.to_thread(_AGENTS_JSON.read_text, encoding="utf-8") + except FileNotFoundError: + raise CatalogueError("agents.json not found") + try: + data = json.loads(raw) + return AgentCatalogue.model_validate(data) + except (json.JSONDecodeError, ValidationError): + raise CatalogueError("agents.json is malformed") + + +async def get_catalogue() -> AgentCatalogue: + """Return the cached catalogue, loading from agents.json on first call.""" + global _catalogue + if _catalogue is None: + _catalogue = await _load() + return _catalogue diff --git a/src/app/ui/agents.json b/src/app/ui/agents.json new file mode 100644 index 0000000..023524e --- /dev/null +++ b/src/app/ui/agents.json @@ -0,0 +1,142 @@ +{ + "agents": [ + { + "type": "agent", + "name": "planner", + "role": "Read-only; writes docs/plans/.md", + "description": "Read-only research agent. Scans CLAUDE.md, existing source files, and any linked spec documents to produce a structured implementation plan at docs/plans/.md. Identifies the files to create or modify, the commit sequence, and any architectural constraints. Does not write source or test files. Invoke with /plan before starting any implementation work.", + "icon": "map", + "connects_to": [] + }, + { + "type": "agent", + "name": "implementer", + "role": "Writes src/ + tests/ only; reads plan first", + "description": "Code-writing agent that reads the plan file produced by the planner and builds the feature on a feat/ branch. Writes only src/ and tests/ — never touches CLAUDE.md, docs/, or config files. Commits models, store, routes, and tests as separate git commits in that order. Invoke with /implement after a plan exists in docs/plans/.", + "icon": "code-2", + "connects_to": ["planner"] + }, + { + "type": "agent", + "name": "ai-service-generator", + "role": "Specialist implementer for Anthropic SDK / LLM features", + "description": "Specialist variant of the implementer for features that call the Anthropic SDK directly. Handles streaming responses via async generators, tool use definitions and dispatch loops, multi-agent orchestration with subagent spawning, and prompt caching headers. Use instead of the general implementer whenever the plan involves direct Claude API calls, MCP server integration, or token-counting logic.", + "icon": "bot", + "connects_to": [] + }, + { + "type": "agent", + "name": "architecture-reviewer", + "role": "Read-only; BCE layer compliance and import dependency flow", + "description": "Read-only agent that checks BCE (Boundary-Control-Entity) layer compliance and import dependency direction across the entire codebase. Flags routes importing from other routes, services leaking into model files, and any inward dependency rule violation. Also checks that every router is mounted in main.py and that no circular imports exist. Invoked automatically by the /review skill.", + "icon": "layers", + "connects_to": [] + }, + { + "type": "agent", + "name": "performance-reviewer", + "role": "Read-only; N+1 queries, blocking I/O, async anti-patterns", + "description": "Read-only agent that scans for async anti-patterns in FastAPI/Python code: blocking I/O calls inside async functions (file reads, subprocess calls, sync DB clients), N+1 query patterns in ORM loops, large in-memory aggregations, and missing connection pool configuration. Reports file paths and line numbers. Invoked automatically by the /review skill.", + "icon": "zap", + "connects_to": [] + }, + { + "type": "agent", + "name": "security-reviewer", + "role": "Read-only; secrets/input validation/OWASP-lite", + "description": "Read-only agent that checks for hardcoded secrets and API keys, insufficient input validation at system boundaries, and OWASP Top-10 vulnerabilities including XSS, SQL/command injection, broken authentication, and insecure direct object references. Also verifies that exception handlers never leak stack traces to clients. Invoked automatically by the /review skill.", + "icon": "shield", + "connects_to": [] + } + ], + "skills": [ + { + "type": "skill", + "name": "fastapi-conventions", + "role": "Auto-loads for src/app/routes/, models/, main.py", + "description": "Injects FastAPI project conventions whenever you edit route handlers, models, or main.py. Enforces: async def on all route handlers, Pydantic v2 method names (.model_dump() / .model_validate(), never .dict()), Depends() injection for every collaborator, exception mapping via @app.exception_handler, and one router per resource mounted in main.py via include_router(). TestClient is forbidden — all tests must use httpx.AsyncClient + ASGITransport.", + "icon": "route", + "connects_to": [] + }, + { + "type": "skill", + "name": "pytest-patterns", + "role": "Auto-loads for tests/, conftest.py", + "description": "Injects test conventions when editing files under tests/ or conftest.py. Enforces: httpx.AsyncClient + ASGITransport transport (TestClient is banned), @pytest_asyncio.fixture for async fixtures (not @pytest.fixture), asyncio_mode = auto set in pyproject.toml so no manual event loop management is needed, and pytest.mark.parametrize over loops. Requires a minimum of 3 tests per route: happy path, 422 validation failure, and 404 not found.", + "icon": "check-circle", + "connects_to": [] + }, + { + "type": "skill", + "name": "uv-workflows", + "role": "Auto-loads for pyproject.toml, uv.lock", + "description": "Injects dependency management conventions when editing pyproject.toml or uv.lock. All package operations must go through uv add (runtime) or uv add --dev (dev/test). Blocks pip install, pip3, and poetry add. Never touch uv.lock by hand — always commit both pyproject.toml and uv.lock together. Run tools (pytest, ruff, mypy) directly without a uv run prefix.", + "icon": "package", + "connects_to": [] + }, + { + "type": "skill", + "name": "infrastructure", + "role": "Auto-loads for Dockerfile, docker-compose*, CI workflows, Helm", + "description": "Injects infrastructure patterns when editing Dockerfiles, docker-compose files, GitHub Actions workflows, or Helm charts. Covers: multi-stage Docker builds to minimise image size, health check endpoints wired to /health/live and /health/ready, secret injection via environment variables (never baked into images), and deployment readiness probes. Also enforces that the quality gate (ruff, mypy, pytest) runs in CI before any deploy step.", + "icon": "server", + "connects_to": [] + }, + { + "type": "skill", + "name": "spec-feature", + "role": "2-phase interview → docs/specs// before planning", + "description": "Runs a structured 2-phase requirements interview before any implementation begins. Phase 1 captures feature intent, user stories, and acceptance criteria. Phase 2 refines scope, identifies edge cases, and surfaces integration constraints. Outputs three documents under docs/specs//: requirements.md, design.md, and tasks.md. Run /spec-feature before /plan on any non-trivial feature to avoid scope drift during implementation.", + "icon": "clipboard-list", + "connects_to": ["planner"] + }, + { + "type": "skill", + "name": "openapi", + "role": "Auto-loads for openapi*.yml/json; spec authoring and codegen", + "description": "Injects OpenAPI conventions when editing openapi*.yml or openapi*.json files. Supports spec-first development: validates schema correctness, checks that request/response contracts are consistent, and can generate FastAPI route stubs directly from the spec. Use when the API contract needs to be agreed upon and frozen before implementation starts, or when integrating with external consumers that depend on the schema.", + "icon": "file-json", + "connects_to": [] + }, + { + "type": "skill", + "name": "review", + "role": "Inline BCE + FastAPI + Python checklist for ad-hoc code review", + "description": "Invokes architecture-reviewer, performance-reviewer, and security-reviewer in parallel, then aggregates their findings into a single report. Runs the full three-layer quality check: BCE layer compliance, async performance anti-patterns, and security vulnerabilities. Use /review on any branch before opening a pull request. Pass --comment to post findings as inline PR comments, or --fix to apply safe fixes automatically.", + "icon": "search", + "connects_to": ["architecture-reviewer", "performance-reviewer", "security-reviewer"] + }, + { + "type": "skill", + "name": "doc", + "role": "Reads source → writes project docs into docs/", + "description": "Reads the current source tree — routes, models, services, and config — and writes human-readable documentation into docs/. Useful for generating onboarding guides, architecture overviews, or API reference pages. Does not modify any source files. Output format is Markdown. Run after a feature is merged to keep documentation in sync with the implementation.", + "icon": "book-open", + "connects_to": [] + }, + { + "type": "skill", + "name": "blog-post", + "role": "Audience interview → structured technical blog post", + "description": "Runs an audience and topic scoping interview, then produces a structured technical blog post draft. The interview establishes the target reader, the key insight to convey, and the appropriate depth. Output is a Markdown draft with intro, background context, implementation walkthrough with code snippets, and a conclusion. Written to docs/blog/.md.", + "icon": "newspaper", + "connects_to": [] + }, + { + "type": "skill", + "name": "frontend", + "role": "Auto-loads for *.html, ui/**; Jinja2, Tailwind, HTMX patterns", + "description": "Injects frontend conventions when editing HTML files or files under ui/**. Covers Jinja2 template syntax and template inheritance, Tailwind CSS utility class composition, and HTMX attribute patterns (hx-get, hx-post, hx-swap, hx-target) for dynamic updates without a bundled JavaScript framework. Also enforces that user-supplied values rendered into templates are escaped to prevent XSS.", + "icon": "layout-dashboard", + "connects_to": [] + }, + { + "type": "skill", + "name": "infografik", + "role": "AI image generation via Hugging Face FLUX.1 → docs/assets/", + "description": "Generates images via the Hugging Face FLUX.1 diffusion model and saves the output to docs/assets/. Use for architecture diagrams, data flow illustrations, or feature concept art. Requires a Hugging Face API token set in the environment. Prompts should describe the technical concept precisely — the model is not architecture-aware so spatial relationships need to be explicit in the prompt text.", + "icon": "image", + "connects_to": [] + } + ] +} diff --git a/src/app/ui/index.html b/src/app/ui/index.html index c29b6f5..de5de1c 100644 --- a/src/app/ui/index.html +++ b/src/app/ui/index.html @@ -1,25 +1,152 @@ - - - {{ app_name }} - + + + {{ app_name }} + -
-
-
-

{{ app_name }}

- v{{ version }} - {{ environment }} -
-

FastAPI service running in Docker

- -
-
+
+
+

{{ app_name }}

+

agentic-coding-template wires a set of Claude Code agents and + auto-loading skills into a FastAPI project. Agents are discrete, explicitly invoked + workers — the planner writes specs, the implementer writes code, and the reviewer + suite audits the result. Skills attach context automatically when you open relevant + files, injecting project conventions into the active session without manual + prompting. The stack enforces async-first FastAPI patterns, Pydantic v2, + BCE layer architecture, and uv-based dependency management. Quality is gated by + ruff, mypy --strict, and pytest-asyncio on every push.

+
+
+
+
+

Agents

+
+
+
+

Skills

+
+
+
+ + - \ No newline at end of file + diff --git a/src/app/ui/style.css b/src/app/ui/style.css index baad5f0..b039140 100644 --- a/src/app/ui/style.css +++ b/src/app/ui/style.css @@ -6,14 +6,8 @@ body { color: #e2e8f0; min-height: 100vh; display: flex; - align-items: center; - justify-content: center; -} - -main { - width: 100%; - max-width: 480px; - padding: 1.5rem; + flex-direction: column; + align-items: stretch; } .card { @@ -79,4 +73,152 @@ h1 { color: #94a3b8; } -.link-btn.secondary:hover { background: #3d4268; } \ No newline at end of file +.link-btn.secondary:hover { background: #3d4268; } + +/* ── Hero ──────────────────────────────────────────────── */ + +.hero { + width: 100%; + padding: 3rem 1.5rem 2rem; +} + +.hero-inner { + max-width: 960px; + margin: 0 auto; +} + +.hero-inner h1 { + font-size: 2rem; + font-weight: 700; + color: #f8fafc; + margin-bottom: 0.75rem; +} + +.hero-desc { + font-size: 1.1rem; + color: #94a3b8; + line-height: 1.6; +} + +/* ── Catalogue ──────────────────────────────────────────── */ + +.catalogue { + max-width: 960px; + margin: 0 auto; + padding: 0 1.5rem 3rem; + width: 100%; +} + +.cat-section { + margin-bottom: 2.5rem; +} + +.cat-section h2 { + font-size: 1.25rem; + color: #f8fafc; + margin-bottom: 1rem; +} + +/* ── Card grid ──────────────────────────────────────────── */ + +.card-grid { + display: grid; + gap: 1rem; + grid-template-columns: repeat(3, 1fr); + align-items: start; +} + +@media (max-width: 1023px) { + .card-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 639px) { + .card-grid { grid-template-columns: 1fr; } +} + +/* ── Agent card ─────────────────────────────────────────── */ + +.agent-card { + background: #1e2130; + border: 1px solid #2d3148; + border-radius: 0.75rem; + padding: 1.25rem; + cursor: pointer; + transition: border-color 0.15s; + min-height: 9rem; +} + +.agent-card:hover { border-color: #4c6ef5; } + +.card-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.5rem; +} + +.card-icon { + width: 24px; + height: 24px; + color: #60a5fa; + flex-shrink: 0; +} + +.card-name { + font-weight: 600; + color: #f8fafc; + flex: 1; +} + +.type-agent { background: #1e3a5f; color: #60a5fa; } +.type-skill { background: #2e1e5f; color: #a78bfa; } + +.card-role { + font-size: 0.85rem; + color: #64748b; + margin-bottom: 0.5rem; + line-height: 1.5; +} + +.card-detail { + border-top: 1px solid #2d3148; + padding-top: 0.75rem; + margin-top: 0.75rem; +} + +.connects-chips { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.chip { + padding: 0.2rem 0.6rem; + border-radius: 9999px; + font-size: 0.75rem; + font-family: monospace; + background: #2d3148; + color: #94a3b8; +} + +.chip-on-demand { + background: transparent; + border: 1px dashed #3d4268; + color: #64748b; + font-style: italic; +} + +.card-desc-full { + font-size: 0.85rem; + color: #94a3b8; + line-height: 1.6; + margin-bottom: 0.75rem; +} + +.card-chips-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #475569; + margin-bottom: 0.4rem; +} \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py index 1d70b20..bf078e1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,18 @@ from collections.abc import AsyncGenerator +import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient +import app.services.agent_catalogue as _catalogue_module from app.main import app +@pytest.fixture(autouse=True) +def reset_catalogue_cache() -> None: + _catalogue_module._catalogue = None + + @pytest_asyncio.fixture async def client() -> AsyncGenerator[AsyncClient, None]: async with AsyncClient( diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..781df40 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,80 @@ +"""Tests for GET /api/agents.""" + +from pathlib import Path + +import pytest +from httpx import AsyncClient + +import app.services.agent_catalogue as agent_catalogue_module + + +async def test_list_agents_happy_path(client: AsyncClient) -> None: + """GET /api/agents returns 200 when agents.json is present and valid.""" + response = await client.get("/api/agents") + assert response.status_code == 200 + + +async def test_list_agents_response_keys(client: AsyncClient) -> None: + """Response body contains top-level 'agents' and 'skills' keys.""" + response = await client.get("/api/agents") + assert response.status_code == 200 + body = response.json() + assert "agents" in body + assert "skills" in body + + +async def test_list_agents_agents_non_empty(client: AsyncClient) -> None: + """The 'agents' list contains at least one entry.""" + response = await client.get("/api/agents") + assert response.status_code == 200 + body = response.json() + assert len(body["agents"]) > 0 + + +async def test_list_agents_skills_non_empty(client: AsyncClient) -> None: + """The 'skills' list contains at least one entry.""" + response = await client.get("/api/agents") + assert response.status_code == 200 + body = response.json() + assert len(body["skills"]) > 0 + + +@pytest.mark.parametrize("field", ["type", "name", "role", "icon", "connects_to"]) +async def test_list_agents_first_entry_has_required_fields( + client: AsyncClient, field: str +) -> None: + """First entry in 'agents' has all required fields.""" + first = (await client.get("/api/agents")).json()["agents"][0] + assert field in first + + +async def test_list_agents_missing_file( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Returns 500 when agents.json does not exist on the filesystem.""" + missing = tmp_path / "nonexistent.json" + monkeypatch.setattr(agent_catalogue_module, "_AGENTS_JSON", missing) + response = await client.get("/api/agents") + assert response.status_code == 500 + + +async def test_list_agents_malformed_json( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Returns 500 when agents.json contains invalid JSON.""" + bad_file = tmp_path / "agents.json" + bad_file.write_text("{", encoding="utf-8") + monkeypatch.setattr(agent_catalogue_module, "_AGENTS_JSON", bad_file) + response = await client.get("/api/agents") + assert response.status_code == 500 + + +async def test_list_agents_schema_validation_error( + client: AsyncClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Returns 500 when agents.json is valid JSON but fails schema validation.""" + bad_file = tmp_path / "agents.json" + bad_file.write_text('{"agents": "not-a-list", "skills": []}', encoding="utf-8") + monkeypatch.setattr(agent_catalogue_module, "_AGENTS_JSON", bad_file) + response = await client.get("/api/agents") + assert response.status_code == 500