From 388005e0d7ccc67f2c465b06d8835338af5ff113 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Tue, 16 Jun 2026 17:56:16 +0200 Subject: [PATCH 01/13] add AgentOrSkill and AgentCatalogue Pydantic models --- src/app/models/agent.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/app/models/agent.py diff --git a/src/app/models/agent.py b/src/app/models/agent.py new file mode 100644 index 0000000..fb0937b --- /dev/null +++ b/src/app/models/agent.py @@ -0,0 +1,16 @@ +from typing import Literal + +from pydantic import BaseModel + + +class AgentOrSkill(BaseModel): + type: Literal["agent", "skill"] + name: str + role: str + icon: str + connects_to: list[str] + + +class AgentCatalogue(BaseModel): + agents: list[AgentOrSkill] + skills: list[AgentOrSkill] From 3b831e5e0a2565b9fd12dabe6cf538e5c2578310 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Tue, 16 Jun 2026 17:56:45 +0200 Subject: [PATCH 02/13] add agents.json catalogue data --- src/app/ui/agents.json | 125 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/app/ui/agents.json diff --git a/src/app/ui/agents.json b/src/app/ui/agents.json new file mode 100644 index 0000000..7add2b4 --- /dev/null +++ b/src/app/ui/agents.json @@ -0,0 +1,125 @@ +{ + "agents": [ + { + "type": "agent", + "name": "planner", + "role": "Read-only; writes docs/plans/.md", + "icon": "map", + "connects_to": [] + }, + { + "type": "agent", + "name": "implementer", + "role": "Writes src/ + tests/ only; reads plan first", + "icon": "code-2", + "connects_to": ["planner"] + }, + { + "type": "agent", + "name": "ai-service-generator", + "role": "Specialist implementer for Anthropic SDK / LLM features", + "icon": "bot", + "connects_to": [] + }, + { + "type": "agent", + "name": "architecture-reviewer", + "role": "Read-only; BCE layer compliance and import dependency flow", + "icon": "layers", + "connects_to": [] + }, + { + "type": "agent", + "name": "performance-reviewer", + "role": "Read-only; N+1 queries, blocking I/O, async anti-patterns", + "icon": "zap", + "connects_to": [] + }, + { + "type": "agent", + "name": "security-reviewer", + "role": "Read-only; secrets/input validation/OWASP-lite", + "icon": "shield", + "connects_to": [] + } + ], + "skills": [ + { + "type": "skill", + "name": "fastapi-conventions", + "role": "Auto-loads for src/app/routes/, models/, main.py", + "icon": "route", + "connects_to": [] + }, + { + "type": "skill", + "name": "pytest-patterns", + "role": "Auto-loads for tests/, conftest.py", + "icon": "check-circle", + "connects_to": [] + }, + { + "type": "skill", + "name": "uv-workflows", + "role": "Auto-loads for pyproject.toml, uv.lock", + "icon": "package", + "connects_to": [] + }, + { + "type": "skill", + "name": "infrastructure", + "role": "Auto-loads for Dockerfile, docker-compose*, CI workflows, Helm", + "icon": "server", + "connects_to": [] + }, + { + "type": "skill", + "name": "spec-feature", + "role": "2-phase interview → docs/specs// before planning", + "icon": "clipboard-list", + "connects_to": ["planner"] + }, + { + "type": "skill", + "name": "openapi", + "role": "Auto-loads for openapi*.yml/json; spec authoring and codegen", + "icon": "file-json", + "connects_to": [] + }, + { + "type": "skill", + "name": "review", + "role": "Inline BCE + FastAPI + Python checklist for ad-hoc code review", + "icon": "search", + "connects_to": ["architecture-reviewer", "performance-reviewer", "security-reviewer"] + }, + { + "type": "skill", + "name": "doc", + "role": "Reads source → writes project docs into docs/", + "icon": "book-open", + "connects_to": [] + }, + { + "type": "skill", + "name": "blog-post", + "role": "Audience interview → structured technical blog post", + "icon": "newspaper", + "connects_to": [] + }, + { + "type": "skill", + "name": "frontend", + "role": "Auto-loads for *.html, ui/**; Jinja2, Tailwind, HTMX patterns", + "icon": "layout-dashboard", + "connects_to": [] + }, + { + "type": "skill", + "name": "infografik", + "role": "AI image generation via Hugging Face FLUX.1 → docs/assets/", + "icon": "image", + "connects_to": [] + } + ] +} From 90331a70affb78b089c4c6049bdc574f4c35762c Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Tue, 16 Jun 2026 17:57:07 +0200 Subject: [PATCH 03/13] add agent_catalogue service --- src/app/services/__init__.py | 0 src/app/services/agent_catalogue.py | 23 +++++++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/app/services/__init__.py create mode 100644 src/app/services/agent_catalogue.py 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..5c76bc7 --- /dev/null +++ b/src/app/services/agent_catalogue.py @@ -0,0 +1,23 @@ +import json +from pathlib import Path + +from fastapi import HTTPException +from pydantic import ValidationError + +from app.models.agent import AgentCatalogue + +_AGENTS_JSON: Path = Path(__file__).resolve().parent.parent / "ui" / "agents.json" + + +async def get_catalogue() -> AgentCatalogue: + """Read, parse, and validate agents.json on every request.""" + try: + raw = _AGENTS_JSON.read_text(encoding="utf-8") + except FileNotFoundError: + raise HTTPException(status_code=500, detail="agents.json not found") + + try: + data = json.loads(raw) + return AgentCatalogue.model_validate(data) + except (json.JSONDecodeError, ValidationError): + raise HTTPException(status_code=500, detail="agents.json is malformed") From aadab003925aafbfe33688e0446534b75cfa3515 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Tue, 16 Jun 2026 17:58:26 +0200 Subject: [PATCH 04/13] add GET /api/agents route and wire into main --- src/app/main.py | 3 +++ src/app/routes/agents.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 src/app/routes/agents.py diff --git a/src/app/main.py b/src/app/main.py index 79ab2d2..86cc528 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -10,6 +10,7 @@ from fastapi.templating import Jinja2Templates from app.core.config import settings +from app.routes.agents import router as agents_router logger = logging.getLogger(__name__) @@ -39,6 +40,8 @@ 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") + # --Health-- @app.get("/health/live", response_model=None, status_code=200) 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 From c9b2ff72d6259aec2ec6909d4de9c7bf52ef2497 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Tue, 16 Jun 2026 17:59:08 +0200 Subject: [PATCH 05/13] redesign landing page UI --- src/app/ui/index.html | 119 ++++++++++++++++++++++++++++++++------ src/app/ui/style.css | 130 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 22 deletions(-) diff --git a/src/app/ui/index.html b/src/app/ui/index.html index c29b6f5..8110a3b 100644 --- a/src/app/ui/index.html +++ b/src/app/ui/index.html @@ -1,25 +1,106 @@ - - - {{ app_name }} - + + + {{ app_name }} + -
-
-
-

{{ app_name }}

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

FastAPI service running in Docker

- -
-
+
+
+

{{ app_name }}

+

A collection of specialised agents and auto-loading skills that + bring Claude Code patterns to every layer of your FastAPI service — from planning + and implementation to review, testing, and deployment.

+
+
+
+
+

Agents

+
+
+
+

Skills

+
+
+
+ + - \ No newline at end of file + diff --git a/src/app/ui/style.css b/src/app/ui/style.css index baad5f0..fd5f8e6 100644 --- a/src/app/ui/style.css +++ b/src/app/ui/style.css @@ -6,8 +6,7 @@ body { color: #e2e8f0; min-height: 100vh; display: flex; - align-items: center; - justify-content: center; + flex-direction: column; } main { @@ -79,4 +78,129 @@ 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; + max-width: 640px; + 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); +} + +@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; +} + +.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; +} \ No newline at end of file From 3e9b448083ee119fc90cf63f72e3aa33b39b73b8 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Tue, 16 Jun 2026 17:59:33 +0200 Subject: [PATCH 06/13] allow git commit --- .claude/settings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 *)" From 28ee447b9978b66a6332cefa71808027003e989a Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Tue, 16 Jun 2026 17:59:34 +0200 Subject: [PATCH 07/13] add tests for GET /api/agents --- tests/test_agents.py | 69 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_agents.py diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..988e4af --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,69 @@ +"""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 + + +async def test_list_agents_first_entry_has_required_fields(client: AsyncClient) -> None: + """First entry in 'agents' has all required fields.""" + response = await client.get("/api/agents") + assert response.status_code == 200 + first = response.json()["agents"][0] + for field in ("type", "name", "role", "icon", "connects_to"): + assert field in first, f"Missing field: {field}" + + +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 From 3b5ff7d93cdaaccedbb11cbb752833cafa1610a9 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Tue, 16 Jun 2026 18:07:22 +0200 Subject: [PATCH 08/13] Fix blocking I/O, XSS, BCE violation, missing test, and connects_to default --- src/app/main.py | 8 ++++ src/app/models/agent.py | 2 +- src/app/services/agent_catalogue.py | 12 ++++-- src/app/ui/index.html | 67 +++++++++++++++++++++-------- tests/test_agents.py | 11 +++++ 5 files changed, 76 insertions(+), 24 deletions(-) diff --git a/src/app/main.py b/src/app/main.py index 86cc528..f225039 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -11,6 +11,7 @@ from app.core.config import settings from app.routes.agents import router as agents_router +from app.services.agent_catalogue import CatalogueError logger = logging.getLogger(__name__) @@ -43,6 +44,13 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: app.include_router(agents_router, prefix="/api") +@app.exception_handler(CatalogueError) +async def catalogue_error_handler( + request: Request, exc: CatalogueError +) -> JSONResponse: + return JSONResponse(status_code=500, content={"detail": str(exc)}) + + # --Health-- @app.get("/health/live", response_model=None, status_code=200) async def liveness() -> JSONResponse: diff --git a/src/app/models/agent.py b/src/app/models/agent.py index fb0937b..148b0dc 100644 --- a/src/app/models/agent.py +++ b/src/app/models/agent.py @@ -8,7 +8,7 @@ class AgentOrSkill(BaseModel): name: str role: str icon: str - connects_to: list[str] + connects_to: list[str] = [] class AgentCatalogue(BaseModel): diff --git a/src/app/services/agent_catalogue.py b/src/app/services/agent_catalogue.py index 5c76bc7..16a0cb3 100644 --- a/src/app/services/agent_catalogue.py +++ b/src/app/services/agent_catalogue.py @@ -1,7 +1,7 @@ +import asyncio import json from pathlib import Path -from fastapi import HTTPException from pydantic import ValidationError from app.models.agent import AgentCatalogue @@ -9,15 +9,19 @@ _AGENTS_JSON: Path = Path(__file__).resolve().parent.parent / "ui" / "agents.json" +class CatalogueError(Exception): + pass + + async def get_catalogue() -> AgentCatalogue: """Read, parse, and validate agents.json on every request.""" try: - raw = _AGENTS_JSON.read_text(encoding="utf-8") + raw = await asyncio.to_thread(_AGENTS_JSON.read_text, encoding="utf-8") except FileNotFoundError: - raise HTTPException(status_code=500, detail="agents.json not found") + raise CatalogueError("agents.json not found") try: data = json.loads(raw) return AgentCatalogue.model_validate(data) except (json.JSONDecodeError, ValidationError): - raise HTTPException(status_code=500, detail="agents.json is malformed") + raise CatalogueError("agents.json is malformed") diff --git a/src/app/ui/index.html b/src/app/ui/index.html index 8110a3b..9c89fda 100644 --- a/src/app/ui/index.html +++ b/src/app/ui/index.html @@ -28,13 +28,20 @@

Skills

+