docs: add Claude rules and workspace configuration
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 33s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m36s

This commit is contained in:
2026-07-14 13:34:05 -04:00
parent 6271c99173
commit 9f1d85f04b
7 changed files with 418 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
---
description: TheSportsDB client + read-through cache — permanent vs volatile, TTL + PG layers, V1/V2 split
paths:
- "nike/sportsdb.py"
---
# TheSportsDB client & caching
- **Two cache layers.** In-memory TTL cache in `sportsdb.py` (`_CACHE`, 5-min TTL,
keyed by `v1|path|params` / `v2|path`) fronts every HTTP call; the PostgreSQL
cache in `db.py` stores permanent data. `clear_cache()` flushes the in-memory
layer; the dashboard's Clear Cache / `POST /api/cache/invalidate` flushes both.
- **Permanent vs volatile is the core distinction.** Permanent data — teams,
players, leagues, events — is cached to PostgreSQL (read-through: DB first, API on
miss, then upsert). **Volatile data is fetched live every time and never written
to PG**: standings (`v1_standings`), livescores, current fixtures. Don't add
volatile endpoints to the PG cache; don't bypass the cache for permanent lookups.
- **V2 is primary, V1 fills gaps.** V2 (`X-API-KEY` header, premium) covers search/
lookup/list/schedule/livescore. V1 (key in URL path, free key `3` works) covers
standings (`lookuptable.php`), events-by-date, H2H, and the team/player search
the resolver uses. Keep new endpoints in the right versioned helper; both go
through `_get_v1`/`_get_v2` so they inherit the TTL cache.
- **One function per endpoint, returning parsed dicts.** Client functions
`raise_for_status()` and return the JSON dict — they don't format and don't
swallow errors (the *tools* decide how to handle failure). Keep them thin.
- **The free key is `3`.** `SPORTSDB_V1` embeds it in the URL; V2 needs a real
premium key. `check_connection()` probes a lightweight V1 endpoint and returns a
`{connected, latency_ms, backend}` dict for the dashboard — keep that shape.
- **Timeouts are explicit** (default 15s, 8s for the health probe). Keep timeouts
on every outbound call; an upstream hang must not wedge a request.

View File

@@ -0,0 +1,45 @@
---
description: os.getenv + dotenv config, NIKE_ prefix, systemd + docker, fresh-env build order
paths:
- "nike/config.py"
- ".env*"
- "nike.service"
- "Dockerfile"
- "docker-compose.yml"
- "run.py"
- "scripts/**"
---
# Config & deployment
- **Config is `os.getenv` + `python-dotenv`, NOT pydantic-settings.** `config.py`
loads `.env` from the repo root (path-anchored, so it works regardless of CWD) and
reads `NIKE_*` vars into module constants. A new setting = a new
`os.getenv("NIKE_...", default)` **plus** a line in `.env.example`. Do not add a
`Settings` model.
- **Every env var uses the `NIKE_` prefix** (`red_panda_standards.md`). DB config is
**individual parts** (`NIKE_DB_HOST/PORT/USER/PASSWORD/NAME`) — never a single
`DATABASE_URL`. `SERVER_HOST`/`SERVER_PORT` have no default and will raise if
unset (deliberate — a missing port should fail loudly, not bind somewhere random).
- **`.env` is gitignored; only `.env.example` is tracked.** Never commit real
secrets. Keep `.env.example` in sync with the `NIKE_*` vars `config.py` reads.
- **`NIKE_TRUSTED_PROXY` gates `X-Forwarded-*` trust.** `'*'` is safe only because
Nike's port is firewalled to HAProxy; keep that constraint in mind if the topology
changes.
- **Fresh-environment build order:** `pip install -e .``scripts/apply_schema.py`
(provision the PG cache from `schema.sql`) → `cd dashboard && npm install && npm
run build``python run.py`. The dashboard build is a hard prerequisite for
serving `/`. Any change to this flow must keep the fresh path working.
- **Deploy is systemd (`nike.service`) with a Docker option** (`Dockerfile` +
`docker-compose.yml`). Note the checked-in `nike.service` has a **stale
`WorkingDirectory`/`ExecStart`** (`/home/robert/gitea/nike`) — flag it if you
touch the unit; the repo lives at `~/git/nike`. Per the estate venv convention the
runtime venv is `~/env/nike`.
- **`run.py` is the entry point** (`python run.py``nike.server:main` → uvicorn).
Keep it a thin shim.

41
.claude/rules/db.md Normal file
View File

@@ -0,0 +1,41 @@
---
description: psycopg2 pool, get_conn contextmanager, cache-table upserts, cache_meta TTL, schema.sql
paths:
- "nike/db.py"
- "schema.sql"
- "scripts/apply_schema.py"
---
# PostgreSQL cache layer
- **`psycopg2` with a `ThreadedConnectionPool`, not SQLAlchemy, not async.** The
pool is created in the FastAPI lifespan (`create_pool`) and closed on shutdown.
New DB work checks out a connection via the **`get_conn()` contextmanager**
(commit-on-success / rollback-on-exception / return-to-pool). Never open a bare
connection for query work.
- **Exception: `check_connection()` deliberately connects outside the pool** (a
fresh `psycopg2.connect`) so it can report health even when the pool is
unhealthy. Note this is called on every `/ready`, `/metrics`, and `/api/status`
it's a known hot-path cost (flag if you're optimising), not a pattern to copy for
normal queries.
- **Cache writes are upserts** (`cache_team`, `cache_league`, `cache_player`,
`cache_event`, and the event sub-tables). They tolerate the raw TheSportsDB dict
shape. Reads (`query_team`, `query_player_by_id`, `query_roster`) return the
DB-row shape (`name`/`id`/`league_name`). Keep both shapes consistent with what
the tools expect (see cache-api / mcp-tools).
- **`cache_meta` drives TTL freshness** for the volatile-ish rows and powers the
dashboard's "last cache" display. `invalidate_cache("%")` clears it (the Clear
Cache path). Keep meta updates alongside the data upserts they describe.
- **Schema lives in `schema.sql`**, applied by `scripts/apply_schema.py`. There is
**no migration framework** — a schema change is an edit to `schema.sql` plus a
thought about existing cache DBs (the cache is disposable, so a rebuild is usually
fine, but say so). `get_table_counts` lists the canonical tables; keep it in sync
when you add one.
- **Parameterise all SQL.** Never f-string user/API values into a query. The one
f-string on a table name in `get_table_counts` is over a hardcoded allowlist —
keep it that way.

View File

@@ -0,0 +1,46 @@
---
description: dashboard /api routes, tool-runner, metrics middleware, health routes, HAProxy edge, mount order
paths:
- "nike/server.py"
---
# FastAPI dashboard, routing & edge
- **Mount order matters and is load-bearing.** `dashboard.mount("/mcp", _mcp_app)`
goes **before** the SPA `StaticFiles` mount at `/` (which is greedy with
`html=True` SPA fallback). MCP must be mounted first or `/` swallows `/mcp`. The
MCP ASGI app is built early (`mcp.http_app(path="/")`) so its lifespan can be
nested inside the FastAPI lifespan — keep that wiring.
- **`/api/*` routes are thin façades** over `db`, `sportsdb`, and `mcp`. `/api/status`
(health cards), `/api/tools` (derived from `mcp.list_tools()` — the single source
the dashboard reads), `/api/logs` (the in-memory request log), `/api/cache/invalidate`
(flush both caches), `/api/run` (tool-runner: `mcp.get_tool``tool.fn(**args)`),
`/api/v1/telemetry` (browser error sink). Keep `/api/tools` derived, never
hand-maintained.
- **Prometheus middleware skips health/MCP paths.** `_SKIP_METRICS_PREFIXES =
("/live","/ready","/metrics","/mcp")` — probes and MCP transport must not inflate
`nike_http_requests_total`. It also normalises UUIDs in paths to `{id}` to bound
label cardinality. Keep both behaviours; add new health-ish prefixes to the skip
list.
- **`/metrics` is Prometheus exposition, IP-gated in Python.** `_METRICS_ALLOWED_NETS`
= the standard four ranges (`10.10.0.0/24`, `172.16.0.0/12`, `127.0.0.0/8`,
`::1/128`), no auth — HAProxy/Prometheus can't authenticate (`red_panda_standards.md`).
Don't widen it or add app-side auth.
- **Health routes register both slash forms** (`/live` + `/live/`, `/ready` +
`/ready/`). `/ready` returns 503 when `db.check_connection()` reports down. `/live`
is a bare `{"status": "ok"}`.
- **HAProxy is the edge.** `uvicorn.run(..., proxy_headers=True,
forwarded_allow_ips=config.TRUSTED_PROXY_IPS, ws="wsproto")`. The write routes
(`/api/run`, `/api/cache/invalidate`) are **not individually authenticated** —
access control is at HAProxy; the code comment says so. If you add a write route,
keep that assumption explicit; if Nike could be exposed directly, it needs auth.
`/api/v1/telemetry` stays unprotected on purpose (`sendBeacon` can't set headers).
- **Structured logging is re-applied in the lifespan** (`configure_logging` after
`create_pool`) because uvicorn overwrites handlers on `config.load()`. Don't drop
the second call, or JSON logging silently reverts to uvicorn's default.

View File

@@ -0,0 +1,43 @@
---
description: FastMCP tool rules — sync def, string-table returns, premium gating, _log, dual dict shapes
paths:
- "nike/server.py"
---
# MCP tools
- **Tools return plain formatted `str`, not JSON and not Pydantic models.** They
build human-readable ASCII blocks/tables for the LLM (`"=== {name} ==="`, aligned
standings columns, grouped rosters). This is Nike's convention — don't convert a
tool to return JSON or a model to match other estate servers. Match the existing
formatting.
- **Tools are synchronous `def`.** `sportsdb` (`requests`) and `db` (`psycopg2`)
are blocking; FastMCP runs sync tools in a threadpool. Don't make them `async`.
Keep them plain module-level callables — `/api/run` invokes `tool.fn(**args)`
directly.
- **Standard tool shape:** `@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True))`
(every tool here is read-only), a docstring written for Claude, resolve the team
via `_resolve_team` if needed, call the `sportsdb` client, format text, and
`_log(tool, args, duration_ms)` before returning. Not-found returns a friendly
string (`"Team '…' not found."`), never an exception.
- **Premium gating is two things kept in sync:** the runtime check
`config.SPORTSDB_KEY in ('3', '')` (→ return a clear "requires a premium key"
message, or degrade to cached data like `get_roster` does) **and** the
`tags={"premium"}` on the decorator (so `/api/tools` and the dashboard can flag
it). Add both when a new tool needs premium data.
- **Rows come in two shapes — handle both.** A team/player dict is either a raw
TheSportsDB dict (`strTeam`, `idTeam`, `strLeague`) or a cached DB row (`name`,
`id`, `league_name`). Read with `x.get("strTeam") or x.get("name")`. A cache hit
and a cache miss must render identically — don't assume one shape.
- **Per-external-call `try/except … pass` is intentional** so a partial API
failure yields partial results. Keep each API call individually guarded; don't
merge them into one try or turn them into hard failures.
- **When you add/rename a tool, update the hand-written strings too:** the `mcp`
`instructions=` and the `football_analyst()` prompt enumerate tools manually and
do **not** auto-update (unlike `/api/tools`, which derives from `mcp.list_tools()`).

42
.claude/rules/svelte.md Normal file
View File

@@ -0,0 +1,42 @@
---
description: SvelteKit static SPA — derived tool list, telemetry, DaisyUI, no server side
paths:
- "dashboard/src/**"
---
# Dashboard — static SPA (SvelteKit 2 / Svelte 5 / Tailwind 4 + DaisyUI 5)
`npm run check` (svelte-check) is the gate — run it after editing any `.svelte` or
`.ts` and report the result.
**Static SPA (`adapter-static`) served by FastAPI from `dashboard/build` — no Node
at runtime, no server side:**
- **No `+page.server.ts`, no `$lib/server`, no form actions, no private `$env`.**
The dashboard only talks to `/api/*` (and `/mcp` is for external MCP clients, not
the dashboard). Secrets live in the backend.
- **`npm run build` is required before FastAPI can serve `/`** — an unbuilt
dashboard makes the index route return a 503 with build instructions. In dev, the
Vite dev server (`:5173`) proxies `/api` and `/mcp` to `:8000`.
- **The tool list is derived, not hardcoded.** `/tools` fetches `/api/tools` (which
the backend builds from `mcp.list_tools()`), so it stays in sync with the
registered MCP tools automatically. Don't hand-maintain a tool list in the
frontend — render whatever `/api/tools` returns, including the `premium` flag.
- **Two routes:** `/` (status: cache/API/MCP health cards, followed teams, tools
list, request log) and `/tools` (interactive runner → `POST /api/run`). Shared
fetch/types live in `src/lib/api.ts` / `src/lib/types.ts` — update types alongside
backend response shape changes.
**Runes & types:**
- Runes mode (`$state`, `$derived`, `$props`, `$effect`); `$derived` for computed,
`$effect` only for genuine side effects. Props via `let { … } = $props()` (typed),
no `export let`.
- `onMount` cleanup must be synchronous.
**Browser telemetry:** report JS errors to `POST /api/v1/telemetry` (the
unprotected sink) so browser failures surface in Loki at WARNING — don't let them
be silently lost (`red_panda_standards.md`).
Styling: Tailwind 4 + DaisyUI 5 components. No new CSS frameworks. No emojis in
the UI — use an icon set.