From c08cf38fe7a4cc66c7dc7d7462640de1c1d1d5c6 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Sat, 6 Jun 2026 21:18:55 +0200 Subject: [PATCH 01/12] Add agentic coding template: agents, hooks, skills, CRUD demo Phases 1-5 of the agentic-coding-template plan: - Foundation: fix TestClient violations, AsyncClient fixtures, full CLAUDE.md - Overhaul: quality+security reviewer split, inline pre-push quality gate, skill path matchers, updated /review and /ship commands - Inventory: docs/inventory.md covering every .claude/ artifact - Demo feature: /items CRUD (models, store, 5 routes, 15 async tests) - Docs: README, WALKTHROUGH, PROMPTS, AGENTIC_WORKFLOW, TROUBLESHOOTING --- .claude/agents/implementer.md | 48 +++++++ .claude/agents/planner.md | 37 +++++ .claude/agents/quality-reviewer.md | 40 ++++++ .claude/agents/security-reviewer.md | 39 +++++ .claude/commands/implement.md | 12 ++ .claude/commands/plan.md | 16 +++ .claude/commands/review.md | 25 ++++ .claude/commands/ship.md | 33 +++++ .claude/hooks/README.md | 20 +++ .claude/hooks/autoformat_python.py | 29 ++++ .claude/hooks/block_bash_dangers.py | 34 +++++ .claude/hooks/block_secrets_and_env.py | 44 ++++++ .claude/hooks/enforce_uv.py | 24 ++++ .claude/hooks/pre_push_quality_gate.py | 31 ++++ .claude/settings.json | 125 ++++++++++++++++ .claude/skills/fastapi-conventions/SKILL.md | 46 ++++++ .claude/skills/pytest-patterns/SKILL.md | 64 +++++++++ .claude/skills/uv-workflows/SKILL.md | 44 ++++++ .claudeignore | 32 +++++ .devcontainer/devcontainer.json | 6 +- .github/workflows/ci.yml | 4 +- .gitignore | 5 + CLAUDE.md | 121 ++++++++++++++++ Dockerfile | 28 ++-- README.md | 113 ++++++++++----- WALKTHROUGH.md | 149 ++++++++++++++++++++ docker-compose.yml | 8 +- docs/AGENTIC_WORKFLOW.md | 145 +++++++++++++++++++ docs/PROMPTS.md | 103 ++++++++++++++ docs/TROUBLESHOOTING.md | 117 +++++++++++++++ docs/inventory.md | 71 ++++++++++ pyproject.toml | 13 +- src/app/core/config.py | 14 +- src/app/main.py | 45 +++--- src/app/models/__init__.py | 0 src/app/models/item.py | 22 +++ src/app/routes/__init__.py | 0 src/app/routes/items.py | 64 +++++++++ src/app/store/__init__.py | 0 src/app/store/item_store.py | 55 ++++++++ tests/conftest.py | 14 ++ tests/test_items.py | 120 ++++++++++++++++ tests/test_main.py | 31 ++-- uv.lock | 130 ++++++++--------- 44 files changed, 1963 insertions(+), 158 deletions(-) create mode 100644 .claude/agents/implementer.md create mode 100644 .claude/agents/planner.md create mode 100644 .claude/agents/quality-reviewer.md create mode 100644 .claude/agents/security-reviewer.md create mode 100644 .claude/commands/implement.md create mode 100644 .claude/commands/plan.md create mode 100644 .claude/commands/review.md create mode 100644 .claude/commands/ship.md create mode 100644 .claude/hooks/README.md create mode 100644 .claude/hooks/autoformat_python.py create mode 100644 .claude/hooks/block_bash_dangers.py create mode 100644 .claude/hooks/block_secrets_and_env.py create mode 100644 .claude/hooks/enforce_uv.py create mode 100644 .claude/hooks/pre_push_quality_gate.py create mode 100644 .claude/settings.json create mode 100644 .claude/skills/fastapi-conventions/SKILL.md create mode 100644 .claude/skills/pytest-patterns/SKILL.md create mode 100644 .claude/skills/uv-workflows/SKILL.md create mode 100644 .claudeignore create mode 100644 CLAUDE.md create mode 100644 WALKTHROUGH.md create mode 100644 docs/AGENTIC_WORKFLOW.md create mode 100644 docs/PROMPTS.md create mode 100644 docs/TROUBLESHOOTING.md create mode 100644 docs/inventory.md create mode 100644 src/app/models/__init__.py create mode 100644 src/app/models/item.py create mode 100644 src/app/routes/__init__.py create mode 100644 src/app/routes/items.py create mode 100644 src/app/store/__init__.py create mode 100644 src/app/store/item_store.py create mode 100644 tests/test_items.py diff --git a/.claude/agents/implementer.md b/.claude/agents/implementer.md new file mode 100644 index 0000000..0a53732 --- /dev/null +++ b/.claude/agents/implementer.md @@ -0,0 +1,48 @@ +--- +name: implementer +model: claude-sonnet-4-6 +tools: + - Read + - Edit + - Write + - Glob + - Grep + - Bash +--- + +You are the **implementer** agent. You turn a plan from `docs/plans/` into working, tested code. + +## Allowed write targets + +- `src/` — all subdirectories +- `tests/` — all subdirectories + +## Forbidden write targets + +Never create or modify files in: +- `.claude/` +- `pyproject.toml` +- `.github/` +- `Dockerfile` +- `.devcontainer/` + +If the plan requires changes to any forbidden target, surface this as a blocker and stop. + +## Workflow + +1. Read the plan file in full before writing any code. +2. Read all existing files you intend to modify. +3. Implement models → store → routes → wire into `main.py` → tests, in that order. +4. Tests are **part of this brief** — do not skip them. +5. Every new route must have at minimum 3 tests: happy path, validation failure, not-found. + +## Code standards + +- All routes `async def`. +- `response_model` and `status_code` on every route decorator. +- `Depends()` for every injectable collaborator (stores, settings). +- Pydantic v2 idioms: `.model_dump()`, `.model_validate()`. +- No bare `except`; catch specific exception types. +- Tests use `httpx.AsyncClient` with `ASGITransport` — never `TestClient`. + +Read CLAUDE.md before acting. diff --git a/.claude/agents/planner.md b/.claude/agents/planner.md new file mode 100644 index 0000000..32d2792 --- /dev/null +++ b/.claude/agents/planner.md @@ -0,0 +1,37 @@ +--- +name: planner +model: claude-sonnet-4-6 +tools: + - Read + - Glob + - Grep + - WebFetch +--- + +You are the **planner** agent. Your job is to produce a detailed implementation plan for a feature — nothing more. + +## Constraints + +- **Read-only** against `src/` and `tests/`. Do not edit any source or test files. +- You may write **one output file** only: `docs/plans/.md`. +- Do not invoke implementer or reviewer agents. + +## Output format + +Your plan document must contain these sections in order: + +1. **Scope** — one paragraph describing what changes and what does not. +2. **Endpoints** — markdown table: Method | Path | Request body | Response body | Status codes. +3. **Models** — Pydantic class sketches (field names + types, no full code). +4. **Store interface** — method signatures only (e.g. `get_by_id(id: str) -> Item`). +5. **Test plan** — bulleted list: one line per test case, format `route · scenario · expected status`. +6. **Open questions** — numbered list of anything that needs human decision before implementing. + +## Rules + +- Never speculate about implementation details not derivable from the existing codebase. +- Cite file paths and line numbers when referencing existing code. +- Flag any pre-existing issues (type errors, sync tests) you notice — do not fix them. +- Keep the plan under 150 lines. + +Read CLAUDE.md before acting. diff --git a/.claude/agents/quality-reviewer.md b/.claude/agents/quality-reviewer.md new file mode 100644 index 0000000..8eebf1c --- /dev/null +++ b/.claude/agents/quality-reviewer.md @@ -0,0 +1,40 @@ +--- +name: quality-reviewer +model: claude-haiku-4-5-20251001 +tools: + - Read + - Glob + - Grep + - Bash +--- + +You are the **quality-reviewer** agent. Perform a read-only code review of changes on the current branch, focusing on code quality, style, and CLAUDE.md compliance. + +## Constraints + +- **Read-only** — never edit or create files. +- Bash is restricted to: `git diff`, `git log`, `git status`, `rg`, `ruff check`, `mypy --no-error-summary`. +- Do not invoke other agents. + +## Review checklist + +For each changed file, check: + +1. **Type correctness** — every function has a return type annotation; no implicit `Any`. +2. **Test coverage** — every new route has happy path · validation failure (422) · not-found (404) tests. +3. **CLAUDE.md compliance** — cite the specific CLAUDE.md line number for each violation. +4. **FastAPI conventions** — `async def`, `response_model=`, `status_code=`, `Depends()` on every route. +5. **Test client** — `httpx.AsyncClient + ASGITransport` only; `TestClient` is a hard violation. +6. **Pydantic v2** — `.model_dump()` / `.model_validate()`; no `.dict()` or `.from_orm()`. +7. **Exception handling** — no bare `except`; `HTTPException` with meaningful `detail`. +8. **Forbidden writes** — no changes outside `src/` and `tests/` (no `.claude/`, `pyproject.toml`, `.github/`, `Dockerfile`, `.devcontainer/`). + +## Output format + +Produce a numbered list. Each item: `[N] : (CLAUDE.md L)`. + +If there are no violations, output exactly: `LGTM — no quality violations found.` + +Do not summarize or editorialize beyond the violation list. + +Read CLAUDE.md before acting. diff --git a/.claude/agents/security-reviewer.md b/.claude/agents/security-reviewer.md new file mode 100644 index 0000000..b2b4487 --- /dev/null +++ b/.claude/agents/security-reviewer.md @@ -0,0 +1,39 @@ +--- +name: security-reviewer +model: claude-haiku-4-5-20251001 +tools: + - Read + - Glob + - Grep + - Bash +--- + +You are the **security-reviewer** agent. Perform a read-only security review of changes on the current branch. + +## Constraints + +- **Read-only** — never edit or create files. +- Bash is restricted to: `git diff`, `git log`, `git status`, `rg`. +- Do not invoke other agents. + +## Review checklist + +For each changed file, check: + +1. **Hardcoded secrets** — API keys, tokens, passwords, or credentials in source or tests. +2. **Input validation** — all user-supplied fields validated via Pydantic; no raw string interpolation into queries or shell commands. +3. **Mass assignment** — `.model_validate(body)` used safely; unexpected extra fields are rejected or explicitly allowed via `model_config`. +4. **Path traversal** — file path inputs are sanitised; no `open(user_input)` patterns. +5. **Auth surface** — new endpoints that should require authentication are not accidentally unprotected. +6. **Hook safety** — `.claude/hooks/` changes use only stdlib; no `exec`/`eval` of user data; subprocess calls use list form, not `shell=True`. +7. **Dependency surface** — new `import` statements that introduce network calls or subprocess execution are justified. + +## Output format + +Produce a numbered list. Each item: `[N] :`. + +If there are no issues, output exactly: `LGTM — no security issues found.` + +Do not summarize or editorialize beyond the issue list. + +Read CLAUDE.md before acting. diff --git a/.claude/commands/implement.md b/.claude/commands/implement.md new file mode 100644 index 0000000..2ef5c4d --- /dev/null +++ b/.claude/commands/implement.md @@ -0,0 +1,12 @@ +Read the most recently modified plan in `docs/plans/`, then invoke the **implementer** agent to build it. + +Instructions for the implementer agent: +- Locate the latest plan: `ls -t docs/plans/*.md | head -1` +- Read the plan in full before writing any code +- If not already on a feature branch, create one: `git checkout -b feat/` where feature-name matches the plan filename +- Implement in order: models → store → routes → wire into main.py → tests +- Do not modify files outside `src/` and `tests/` +- Every new route needs happy / validation / not-found tests +- Commit each logical unit separately with a descriptive message + +After implementation is complete, print a summary of files created/modified and suggest running `/review`. diff --git a/.claude/commands/plan.md b/.claude/commands/plan.md new file mode 100644 index 0000000..569bd68 --- /dev/null +++ b/.claude/commands/plan.md @@ -0,0 +1,16 @@ +Read CLAUDE.md, then invoke the **planner** agent to produce an implementation plan for the feature named `$ARGUMENTS`. + +Instructions for the planner agent: +- The feature name is: `$ARGUMENTS` +- Write the plan to `docs/plans/$ARGUMENTS.md` +- Follow the output format in your system prompt exactly (scope, endpoints, models, store interface, test plan, open questions) +- Read all relevant existing source files before writing +- Keep the plan under 150 lines + +If `$ARGUMENTS` ends with `--opus`, use the claude-opus-4-8 model for this planning session (more complex features that need deeper reasoning). + +After the plan is written, print: +``` +Plan written to docs/plans/$ARGUMENTS.md +Next: run /implement to build it. +``` diff --git a/.claude/commands/review.md b/.claude/commands/review.md new file mode 100644 index 0000000..0eb79a1 --- /dev/null +++ b/.claude/commands/review.md @@ -0,0 +1,25 @@ +Invoke **quality-reviewer** AND **security-reviewer** in parallel on the current branch diff. + +Both agents must: +- Run `git diff main...HEAD` to see all changes +- Run `git log main...HEAD --oneline` to understand the commit structure +- Apply their full review checklist from their system prompt + +After both complete, print their outputs under headings: + +``` +## Quality Review + + +## Security Review + +``` + +If either output contains violations: +- List all violations together +- Suggest specific fixes with file and line references +- Ask whether to invoke `/implement` to address them, or proceed to `/ship` +- If fixes are applied, re-run both reviewers (counts as iteration 1) +- Maximum 2 self-correcting iterations before escalating to the user for a manual decision + +If both output LGTM, print: `All clear — run /ship to push.` diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md new file mode 100644 index 0000000..dc7f1b8 --- /dev/null +++ b/.claude/commands/ship.md @@ -0,0 +1,33 @@ +Run the full quality gate. All four checks must pass. + +```bash +ruff format --check . +ruff check . +mypy --strict src/ +pytest -x +``` + +Run each command in sequence. If any command fails: +- Print the full output of the failing command +- Stop and report what needs fixing + +If all four pass, open a pull request: + +```bash +gh pr create --title "" --body "$(cat <<'EOF' +## Summary + + +## Test plan +- [ ] All tests pass (`pytest -x`) +- [ ] Type-checked (`mypy --strict src/`) +- [ ] Lint clean (`ruff check .`) + +Generated with [Claude Code](https://claude.ai/code) +EOF +)" +``` + +Use `git branch --show-current` to get the branch name. Reference the plan file from `docs/plans/` in the summary if one exists for this feature. + +Do NOT write any state file. diff --git a/.claude/hooks/README.md b/.claude/hooks/README.md new file mode 100644 index 0000000..37b40c1 --- /dev/null +++ b/.claude/hooks/README.md @@ -0,0 +1,20 @@ +# Hooks + +| Hook | Event | Purpose | Failure mode prevented | +|---|---|---|---| +| `block_secrets_and_env.py` | PreToolUse Edit\|Write\|MultiEdit | Blocks writes to `.env` and files containing secret patterns (AWS keys, GH tokens, Anthropic keys, private keys) | Accidental secret commit | +| `block_bash_dangers.py` | PreToolUse Bash | Blocks `rm -rf`, force-push, `--hard` reset, push to main, and commit on main branch | Destructive repo/history operations | +| `enforce_uv.py` | PreToolUse Bash | Blocks `pip install`, `pip3 install`, `python -m pip`, `poetry add` | Dependency drift outside uv lockfile | +| `autoformat_python.py` | PostToolUse Edit\|Write\|MultiEdit | Runs `ruff format` then `ruff check --fix` on any changed `.py` file | Stale formatting, auto-fixable lint violations | +| `pre_push_quality_gate.py` | PreToolUse Bash | Blocks `git push` unless `ruff`, `mypy --strict`, and `pytest` all pass inline | Pushing code that fails the quality gate | + +## Exit codes + +- `exit(0)` — allow the tool use to proceed. +- `exit(2)` — block the tool use and print reason to stderr. Never use `exit(1)`. + +## Adding a new hook + +1. Create `.py` in this directory (stdlib only — no third-party imports). +2. Read `sys.stdin` as JSON to access `tool_name` and `tool_input`. +3. Register it in `.claude/settings.json` under the appropriate event and matcher. diff --git a/.claude/hooks/autoformat_python.py b/.claude/hooks/autoformat_python.py new file mode 100644 index 0000000..5405875 --- /dev/null +++ b/.claude/hooks/autoformat_python.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""PostToolUse hook for Edit|Write|MultiEdit — auto-formats changed Python files.""" + +import json +import shutil +import subprocess +import sys + +payload = json.load(sys.stdin) +tool_input = payload.get("tool_input", {}) +tool_name = payload.get("tool_name", "") + +paths: list[str] = [] +if tool_name == "Write": + paths.append(tool_input.get("file_path", "")) +elif tool_name == "Edit": + paths.append(tool_input.get("file_path", "")) +elif tool_name == "MultiEdit": + paths.append(tool_input.get("file_path", "")) + +ruff = shutil.which("ruff") or "ruff" + +for path in paths: + if not path.endswith(".py"): + continue + subprocess.run([ruff, "format", path], capture_output=True) + subprocess.run([ruff, "check", "--fix", path], capture_output=True) + +sys.exit(0) diff --git a/.claude/hooks/block_bash_dangers.py b/.claude/hooks/block_bash_dangers.py new file mode 100644 index 0000000..c0407e7 --- /dev/null +++ b/.claude/hooks/block_bash_dangers.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash — blocks destructive git/shell patterns.""" + +import json +import re +import subprocess +import sys + +DANGER_PATTERNS = [ + r"rm\s+-rf", + r"git\s+push\s+(-f\b|--force(?!-with-lease)|--force$)", + r"git\s+reset\s+--hard", + r"git\s+push\s+.*\bmain\b", +] + +payload = json.load(sys.stdin) +command = payload.get("tool_input", {}).get("command", "") + +for pattern in DANGER_PATTERNS: + if re.search(pattern, command): + print(f"Blocked: dangerous pattern '{pattern}' in command", file=sys.stderr) + sys.exit(2) + +# Block git commit on main branch +if re.search(r"git\s+commit", command): + try: + branch = subprocess.check_output( + ["git", "branch", "--show-current"], text=True + ).strip() + if branch == "main": + print("Blocked: git commit directly on main branch", file=sys.stderr) + sys.exit(2) + except subprocess.SubprocessError: + pass diff --git a/.claude/hooks/block_secrets_and_env.py b/.claude/hooks/block_secrets_and_env.py new file mode 100644 index 0000000..809bd4e --- /dev/null +++ b/.claude/hooks/block_secrets_and_env.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Edit|Write|MultiEdit — blocks .env writes and secret patterns.""" + +import json +import os +import re +import sys + +SECRET_PATTERNS = [ + r"AKIA[A-Z0-9]{16}", + r"ghp_[A-Za-z0-9]{36}", + r"sk-[a-zA-Z0-9]{48}", + r"sk-ant-[a-zA-Z0-9\-_]{90,}", + r"-----BEGIN .* PRIVATE KEY-----", +] + +payload = json.load(sys.stdin) +tool_input = payload.get("tool_input", {}) +tool_name = payload.get("tool_name", "") + +# Collect (file_path, text_to_scan) pairs +targets: list[tuple[str, str]] = [] + +if tool_name == "Write": + targets.append((tool_input.get("file_path", ""), tool_input.get("content", ""))) +elif tool_name == "Edit": + targets.append((tool_input.get("file_path", ""), tool_input.get("new_string", ""))) +elif tool_name == "MultiEdit": + fp = tool_input.get("file_path", "") + for edit in tool_input.get("edits", []): + targets.append((fp, edit.get("new_string", ""))) + +for file_path, text in targets: + if os.path.basename(file_path) == ".env": + print(f"Blocked: direct write to .env file ({file_path})", file=sys.stderr) + sys.exit(2) + for pattern in SECRET_PATTERNS: + match = re.search(pattern, text) + if match: + print( + f"Blocked: secret pattern '{pattern}' found in {file_path}", + file=sys.stderr, + ) + sys.exit(2) diff --git a/.claude/hooks/enforce_uv.py b/.claude/hooks/enforce_uv.py new file mode 100644 index 0000000..77a317d --- /dev/null +++ b/.claude/hooks/enforce_uv.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash — enforces uv over pip/poetry.""" + +import json +import re +import sys + +BLOCKED_PATTERNS = [ + r"pip\s+install", + r"pip3\s+install", + r"python\s+-m\s+pip", + r"poetry\s+add", +] + +payload = json.load(sys.stdin) +command = payload.get("tool_input", {}).get("command", "") + +for pattern in BLOCKED_PATTERNS: + if re.search(pattern, command): + print( + "Blocked: use `uv add ` instead — see uv-workflows skill", + file=sys.stderr, + ) + sys.exit(2) diff --git a/.claude/hooks/pre_push_quality_gate.py b/.claude/hooks/pre_push_quality_gate.py new file mode 100644 index 0000000..9845e73 --- /dev/null +++ b/.claude/hooks/pre_push_quality_gate.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""PreToolUse hook for Bash — runs full quality gate before git push.""" + +import json +import re +import subprocess +import sys + +payload = json.load(sys.stdin) +command = payload.get("tool_input", {}).get("command", "") + +if not re.search(r"git\s+push", command): + sys.exit(0) + +CHECKS = [ + ["ruff", "format", "--check", "."], + ["ruff", "check", "."], + ["mypy", "--strict", "src/"], + ["pytest", "-x", "-q", "--no-header"], +] + +for cmd in CHECKS: + result = subprocess.run(cmd, capture_output=True, text=True) # noqa: S603 + if result.returncode != 0: + label = " ".join(cmd) + print(f"Blocked: quality gate failed — {label}", file=sys.stderr) + if result.stdout.strip(): + print(result.stdout, file=sys.stderr) + if result.stderr.strip(): + print(result.stderr, file=sys.stderr) + sys.exit(2) diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..1c5e668 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,125 @@ +{ + "model": "claude-sonnet-4-6", + "env": { + "UV_NO_SYNC": "1", + "CLAUDE_AUTOCOMPACT_PCT_OVERRIDE": "80" + }, + "respectGitignore": true, + "plansDirectory": "./docs/plans", + "hooks": { + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/block_secrets_and_env.py" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/block_bash_dangers.py" + }, + { + "type": "command", + "command": "python3 .claude/hooks/enforce_uv.py" + }, + { + "type": "command", + "command": "python3 .claude/hooks/pre_push_quality_gate.py" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "python3 .claude/hooks/autoformat_python.py" + } + ] + } + ] + }, + "permissions": { + "allow": [ + "Edit(*)", + "Write(*)", + "Bash(uv *)", + "Bash(pytest *)", + "Bash(ruff *)", + "Bash(mypy *)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git status*)", + "Bash(git show *)", + "Bash(git branch*)", + "Bash(git add *)", + "Bash(git checkout -b *)", + "Bash(git switch -c *)", + "Bash(gh pr view*)", + "Bash(gh pr list*)", + "Bash(gh pr diff*)", + "WebFetch(domain:docs.anthropic.com)", + "WebFetch(domain:support.claude.com)", + "WebFetch(domain:fastapi.tiangolo.com)", + "WebFetch(domain:docs.pydantic.dev)", + "WebFetch(domain:docs.astral.sh)", + "WebFetch(domain:github.com)", + "WebFetch(domain:api.github.com)", + "WebFetch(domain:raw.githubusercontent.com)" + ], + "deny": [ + "Read(./.env)", + "Read(./.env.*)", + "Read(**/.env)", + "Read(**/.env.*)", + "Read(**/*.pem)", + "Read(**/*.key)", + "Read(**/id_rsa*)", + "Read(**/.ssh/**)", + "Read(**/.aws/**)", + "Read(**/credentials)", + "Read(**/secrets.*)", + "Write(./.env)", + "Write(./.env.*)" + ], + "ask": [ + "Bash(rm *)", + "Bash(rmdir *)", + "Bash(shred *)", + "Bash(unlink *)", + "Bash(dd *)", + "Bash(mkfs *)", + "Bash(fdisk *)", + "Bash(chmod *)", + "Bash(chown *)", + "Bash(mv *)", + "Bash(kill *)", + "Bash(killall *)", + "Bash(pkill *)", + "Bash(curl *)", + "Bash(wget *)", + "Bash(npm *)", + "Bash(pnpm *)", + "Bash(yarn *)", + "Bash(pip *)", + "Bash(pip3 *)", + "Bash(apt *)", + "Bash(apt-get *)", + "Bash(docker *)", + "Bash(kubectl *)", + "Bash(git push *)", + "Bash(git commit *)", + "Bash(git reset *)", + "Bash(git rebase *)", + "Bash(git merge *)" + ] + } +} diff --git a/.claude/skills/fastapi-conventions/SKILL.md b/.claude/skills/fastapi-conventions/SKILL.md new file mode 100644 index 0000000..b11b6fd --- /dev/null +++ b/.claude/skills/fastapi-conventions/SKILL.md @@ -0,0 +1,46 @@ +--- +paths: + - "src/app/routes/**" + - "src/app/models/**" + - "src/app/main.py" +trigger: "Use whenever editing src/app/routes/, src/app/models/, src/app/main.py, or implementing FastAPI endpoints, dependencies, response models, lifespan, middleware, or DI." +--- + +# FastAPI Conventions + +## Routes + +- All route handlers must be `async def`. +- Every route decorator must include both `response_model` and `status_code`. +- Use the lifespan context manager pattern — never `@app.on_event`. +- One resource = one router module, mounted in `main.py` via `app.include_router()`. + +## Dependency injection + +- Every swappable collaborator (store, settings, client) must be injected via `Depends()`. +- Never instantiate collaborators inside route handlers. + +## Pydantic v2 + +- Use `.model_dump()` and `.model_validate()` — not `.dict()` or `.from_orm()`. +- Prefer `model_config = {"frozen": True}` on read models. + +## Error handling + +- Map specific exceptions to HTTP responses via `@app.exception_handler`. +- Never use bare `except`; always catch a named exception type. +- Raise `HTTPException` with a meaningful `detail` string. + +## Example route skeleton + +```python +@router.get("/{item_id}", response_model=Item, status_code=200) +async def get_item( + item_id: str, + store: ItemStore = Depends(get_item_store), +) -> Item: + try: + return store.get_by_id(item_id) + except KeyError: + raise HTTPException(status_code=404, detail=f"Item {item_id} not found") +``` diff --git a/.claude/skills/pytest-patterns/SKILL.md b/.claude/skills/pytest-patterns/SKILL.md new file mode 100644 index 0000000..1e2170a --- /dev/null +++ b/.claude/skills/pytest-patterns/SKILL.md @@ -0,0 +1,64 @@ +--- +paths: + - "tests/**" + - "**/conftest.py" +trigger: "Use whenever editing tests/, conftest.py, or writing tests. Triggers on pytest, async test, httpx, fixture, parametrize, or TestClient mentions." +--- + +# Pytest Patterns + +## Async client — never TestClient + +```python +# CORRECT +from httpx import AsyncClient, ASGITransport +from app.main import app + +async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: + response = await client.get("/items") + +# WRONG — do not use +from starlette.testclient import TestClient +``` + +## Fixtures + +- Use `@pytest_asyncio.fixture` for async fixtures (not `@pytest.fixture`). +- Keep fixture chains ≤ 2 levels deep. +- Use `monkeypatch` to override env vars — never mutate `os.environ` directly. + +## Parametrize over loops + +```python +# CORRECT +@pytest.mark.parametrize("name,status", [("", 422), ("x" * 101, 422)]) +async def test_create_invalid(client, name, status): ... + +# WRONG +for name in ["", "x" * 101]: + ... +``` + +## Minimum test coverage per route + +Every new route requires at least 3 tests: + +| # | Scenario | Assert | +|---|---|---| +| 1 | Happy path | Expected status + response shape | +| 2 | Validation failure | 422 + error detail | +| 3 | Not found / missing resource | 404 | + +## conftest.py baseline + +```python +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from app.main import app +from collections.abc import AsyncGenerator + +@pytest_asyncio.fixture +async def client() -> AsyncGenerator[AsyncClient, None]: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c +``` diff --git a/.claude/skills/uv-workflows/SKILL.md b/.claude/skills/uv-workflows/SKILL.md new file mode 100644 index 0000000..9d56123 --- /dev/null +++ b/.claude/skills/uv-workflows/SKILL.md @@ -0,0 +1,44 @@ +--- +paths: + - "pyproject.toml" + - "uv.lock" +trigger: "Use whenever adding/removing/upgrading a dependency, syncing the environment, running Python scripts, or when pip/poetry/venv are mentioned." +--- + +# uv Workflows + +## Adding dependencies + +```bash +uv add # runtime dependency +uv add --dev # dev/test-only dependency +``` + +## Syncing the environment + +```bash +uv sync # install all deps from uv.lock +``` + +## Running commands + +```bash +uv run pytest # run tests +uv run mypy src/ # type check +uv run ruff check . # lint +uv run python script.py +``` + +## Upgrading a specific package + +```bash +uv lock --upgrade-package +uv sync +``` + +## Rules + +- **Never** edit `uv.lock` by hand. +- **Never** use `pip install`, `pip3 install`, `python -m pip`, or `poetry add`. +- Do not create or activate virtualenvs manually — uv manages `.venv` automatically. +- Commit both `pyproject.toml` and `uv.lock` when adding or upgrading a dependency. diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..625a036 --- /dev/null +++ b/.claudeignore @@ -0,0 +1,32 @@ +# Python +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ + +# Build +dist/ +build/ +*.egg-info/ + +# Secrets +.env +.env.* +*.pem +*.key + +# Logs / cache +*.log +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +htmlcov/ +.coverage + +# IDE +.idea/ +.vscode/ + +# Project specific +TODO.md \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 99920aa..76edca8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,13 +1,17 @@ { - "name": "Docker Development Template", + "name": "Agentic Coding Template", "dockerComposeFile": ["../docker-compose.yml"], "service": "app", "workspaceFolder": "/app", "shutdownAction": "stopCompose", + "mounts": [ + "source=claude-code-config-${devcontainerId},target=/home/appuser/.claude,type=volume" + ], "remoteUser": "appuser", "customizations": { "vscode": { "extensions": [ + "anthropic.claude-code", "ms-python.python", "ms-python.vscode-pylance", "ms-python.debugpy", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2637652..4fce6ae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,13 @@ jobs: context: . target: test load: true - tags: docker-dev-template:latest + tags: agentic-coding-template:latest cache-from: type=gha cache-to: type=gha,mode=max - name: Run tests run: | - docker run --name test_container docker-dev-template:latest + docker run --name test_container agentic-coding-template:latest - name: Extract reports if: always() diff --git a/.gitignore b/.gitignore index 1c02665..d0ebf1e 100644 --- a/.gitignore +++ b/.gitignore @@ -111,5 +111,10 @@ venv.bak/ /site +# Claude +.claude/settings.local.json +.claude/state/ +docs/plans/ + # Project-specific TODO.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6449762 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,121 @@ +# CLAUDE.md + +## Project + +`agentic-coding-template` — FastAPI service demonstrating senior-grade Claude Code patterns. +Stack: FastAPI 0.136, Pydantic v2, uv, pytest-asyncio, ruff, mypy --strict, Python 3.11+. + +--- + +## Code style + +- All route handlers **must** be `async def`. +- Every function must have full type annotations — no implicit `Any`. +- Return type on every route decorator: `response_model=` and `status_code=`. +- Pydantic v2 only: `.model_dump()` / `.model_validate()` — never `.dict()` / `.from_orm()`. +- Use `Depends()` for every injectable collaborator. Never instantiate in route handlers. +- Map exceptions to HTTP via `@app.exception_handler`. Never bare `except`. +- One resource = one router module mounted in `main.py` via `app.include_router()`. + +--- + +## Test conventions + +- **`TestClient` is forbidden.** Always use `httpx.AsyncClient` + `ASGITransport`. +- Every new route requires 3 tests minimum: happy path · validation failure (422) · not-found (404). +- Use `@pytest_asyncio.fixture` for async fixtures — not `@pytest.fixture`. +- Use `pytest.mark.parametrize` over loops. +- `asyncio_mode = "auto"` is set in `pyproject.toml` — no manual event loop management. + +Fixture baseline (`tests/conftest.py`): +```python +@pytest_asyncio.fixture +async def client() -> AsyncGenerator[AsyncClient, None]: + async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c: + yield c +``` + +--- + +## Branch rules + +- **Never commit directly on `main`.** Feature branches only: `feat/`. +- Each logical unit is a separate commit (models, store, routes, tests = 4 commits minimum). +- PR title ≤70 chars. Body must reference the plan file from `docs/plans/`. + +--- + +## Forbidden patterns + +| Pattern | Replacement | +|---|---| +| `from starlette.testclient import TestClient` | `httpx.AsyncClient + ASGITransport` | +| `pip install` / `pip3` / `poetry add` | `uv add ` | +| `import os; os.environ[...]` mutate | `monkeypatch.setenv(...)` in tests | +| `@app.on_event(...)` | `@asynccontextmanager` lifespan | +| bare `except:` | catch a named exception type | +| write to `.env` directly | edit `.env.template`, use pydantic-settings | + +--- + +## Dependency management + +Always use `uv`. Never touch `uv.lock` by hand. Commit both `pyproject.toml` and `uv.lock`. + +```bash +uv add # runtime dep +uv add --dev # dev/test dep +``` + +Run tools **directly** — do not prefix with `uv run`: +```bash +pytest -x +mypy --strict src/ +ruff check . +ruff format --check . +``` + +--- + +## Quality gate + +All four must pass before pushing: +```bash +ruff format --check . +ruff check . +mypy --strict src/ +pytest -x +``` +The `pre_push_quality_gate` hook enforces this inline on every `git push`. + +--- + +## Agent / Skill / Command map + +| Artifact | Purpose | +|---|---| +| `planner` agent | Read-only; writes `docs/plans/.md` | +| `implementer` agent | Writes `src/` + `tests/` only; reads plan first | +| `quality-reviewer` agent | Read-only; ruff/mypy/style/CLAUDE.md violations | +| `security-reviewer` agent | Read-only; secrets/input validation/OWASP-lite | +| `fastapi-conventions` skill | Auto-loads for `src/app/routes/`, `models/`, `main.py` | +| `pytest-patterns` skill | Auto-loads for `tests/`, `conftest.py` | +| `uv-workflows` skill | Auto-loads for `pyproject.toml`, `uv.lock` | +| `/plan ` | Invokes planner → `docs/plans/.md` | +| `/implement` | Reads latest plan, creates `feat/` branch, builds | +| `/review` | Invokes quality + security reviewers in parallel | +| `/ship` | Full quality gate + `gh pr create` | + +--- + +## Reuse-first policy + +Before building any new `.claude/` artifact, check `docs/inventory.md` for an existing source +that can be reused or wrapped. Document source + license for every artifact in that table. + +--- + +## /compact preservation + +When context is compacted, preserve: current branch name, latest plan file path, any open +violations from the last `/review` run, and the phase being implemented. diff --git a/Dockerfile b/Dockerfile index 08c22ba..2bbe29a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,9 +29,7 @@ WORKDIR /app ENV UV_COMPILE_BYTECODE=1 \ UV_LINK_MODE=copy \ PATH="/app/.venv/bin:$PATH" \ - UV_PROJECT_ENVIRONMENT=/app/.venv \ - PYTHONPATH="/app/src" - + UV_PROJECT_ENVIRONMENT=/app/.venv # Deps @@ -67,17 +65,18 @@ RUN uv sync --frozen --no-dev --no-cache FROM base AS development COPY --from=deps /app/.venv /app/.venv + COPY pyproject.toml uv.lock ./ +COPY src/ ./src/ -# Stub for the extra group install -RUN mkdir -p src/app && \ - touch src/app/__init__.py +RUN chown -R appuser:appuser /app +USER appuser -# Layer on dev deps (pytest, ruff, mypy, etc. — torch already in venv) -RUN uv sync --frozen --no-install-project --group dev && \ +RUN uv sync --frozen --group dev && \ uv cache clean -USER appuser +RUN curl -fsSL https://claude.ai/install.sh | bash +RUN echo 'unset VIRTUAL_ENV' >> /home/appuser/.bashrc ENV PATH="/home/appuser/.local/bin:$PATH" CMD ["sleep", "infinity"] @@ -92,15 +91,12 @@ COPY pyproject.toml uv.lock ./ ENV UV_COMPILE_BYTECODE=0 -RUN mkdir -p src/app && \ - touch src/app/__init__.py +COPY src/ ./src/ +COPY tests/ ./tests/ RUN uv sync --frozen --no-cache --group test && \ uv cache clean -COPY src/ ./src/ -COPY tests/ ./tests/ - USER appuser CMD ["pytest", "tests/", \ @@ -123,6 +119,6 @@ CMD ["pytest", "tests/", \ # ENV PYTHONDONTWRITEBYTECODE=1 \ # PYTHONUNBUFFERED=1 # -# EXPOSE 8080 +# EXPOSE 8000 # -# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080", "--workers", "2"] \ No newline at end of file +# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] \ No newline at end of file diff --git a/README.md b/README.md index b9de49d..1974377 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,100 @@ -# docker-dev-template +# agentic-coding-template -A containerized Python development template for building web services with FastAPI, Docker Compose multi-stage builds, and `uv` for dependency management. +A FastAPI service demonstrating senior-grade Claude Code patterns: typed agents, auto-trigger skills, inline push gate, and a full Research→Plan→Execute→Review→Ship loop. -Designed as a reusable starting point - production and infrastructure blocks are scaffolded as commented sections, ready to uncomment when needed. +--- +## Quick Start -## Stack +```bash +docker compose up --build # start dev container +cp .env.template .env # configure environment +uv run uvicorn app.main:app --reload --app-dir src +``` -| Tool | Purpose | -|---|---| -| FastAPI + Jinja2 | API framework + server-side UI | -| uv | Dependency management | -| Docker + Compose | Multi-stage build, local dev | -| Pydantic Settings | Environment-aware config | -| Black + isort + ruff | Formatting and linting | -| mypy | Static type checking | -| pytest | Testing | +--- +## Stack +| Tool | Version | Purpose | +|---|---|---| +| FastAPI | 0.136 | Async API framework | +| Pydantic v2 | 2.x | Typed models, settings | +| uv | latest | Dependency management | +| pytest-asyncio | latest | Async test runner | +| ruff | latest | Formatter + linter | +| mypy | latest | Strict static typing | +| httpx | latest | Async test client | -## Quick Start +--- -#### 1. Start dev container -docker compose up --build +## Agent Map -#### 2. Inside the container: create env file and sync deps -cp .env.example .env -uv sync +| Agent | Model | Write targets | Purpose | +|---|---|---|---| +| `planner` | Sonnet 4.6 | `docs/plans/` | Reads codebase, writes implementation plan | +| `implementer` | Sonnet 4.6 | `src/`, `tests/` | Turns plan into working code + tests | +| `quality-reviewer` | Haiku 4.5 | none (read-only) | ruff/mypy/style/CLAUDE.md violations | +| `security-reviewer` | Haiku 4.5 | none (read-only) | Secrets, OWASP-lite, input validation | -#### 3. Start the web service via launch.json or manually -python src/app/main.py +--- -Open the repo in VS Code and use "Reopen in Container" - the .devcontainer/devcontainer.json handles the rest. Start the web service via the provided launch.json debug configuration. +## Skill Roster +| Skill | `paths:` trigger | Purpose | +|---|---|---| +| `fastapi-conventions` | `src/app/routes/**`, `src/app/models/**`, `src/app/main.py` | Route/DI/response-model conventions | +| `pytest-patterns` | `tests/**`, `**/conftest.py` | AsyncClient fixtures, parametrize patterns | +| `uv-workflows` | `pyproject.toml`, `uv.lock` | `uv add`, `uv sync`, `uv run` idioms | +--- -## Docker Stages +## Hook Table -| Stage | Purpose | Used by | +| Hook | Event | What it blocks | |---|---|---| -| `base` | OS, python, uv, venv skeleton — no source, no deps | All stages | -| `builder` | Installs production deps into `/venv` | `production` (via COPY --from) | -| `development` | Full dev tooling, source mounted at runtime | `docker compose up` | -| `test` | Runs pytest with coverage | CI pipeline | -| `production` | Minimal image, pre-built venv | Uncomment when ready to deploy | +| `block_secrets_and_env.py` | PreToolUse Edit\|Write\|MultiEdit | Writes to `.env`; AWS/GH/Anthropic key patterns | +| `block_bash_dangers.py` | PreToolUse Bash | `rm -rf`, force-push, `--hard` reset, commit on main | +| `enforce_uv.py` | PreToolUse Bash | `pip install`, `pip3`, `python -m pip`, `poetry add` | +| `autoformat_python.py` | PostToolUse Edit\|Write\|MultiEdit | Stale formatting; runs `ruff format` + `ruff check --fix` | +| `pre_push_quality_gate.py` | PreToolUse Bash | `git push` unless ruff + mypy + pytest all pass inline | + +See [.claude/hooks/README.md](.claude/hooks/README.md) for exit codes and how to add a hook. + +--- + +## Command Map + +| Command | What it does | +|---|---| +| `/plan ` | Invokes `planner` → writes `docs/plans/.md` | +| `/implement` | Reads latest plan, creates `feat/` branch, builds code | +| `/review` | Invokes `quality-reviewer` + `security-reviewer` in parallel | +| `/ship` | Runs quality gate, then `gh pr create` | + +--- + +## Demo Feature: `/items` CRUD + +Five in-memory endpoints proving the full loop works end-to-end: +``` +POST /items → 201 create item +GET /items → 200 list all +GET /items/{id} → 200 get by id +PUT /items/{id} → 200 update +DELETE /items/{id} → 204 delete +``` +Run the 15 tests: `uv run pytest tests/test_items.py -v` -## Scaffolded (Commented) +--- -Ready to enable when your project needs them: +## Further Reading -- **Production stage** in `Dockerfile` - uses `COPY --from=builder` for minimal image -- **`app-prod` service** in `docker-compose.yml` - with resource limits and healthchecks -- **Postgres service** with healthcheck and persistent volume -- **Docker networking** for multi-service communication +- [WALKTHROUGH.md](WALKTHROUGH.md) — step-by-step session log with real hook blocks and a broken push +- [docs/AGENTIC_WORKFLOW.md](docs/AGENTIC_WORKFLOW.md) — loop diagram, hook semantics, escalation policy +- [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — decode every `Blocked:` message +- [docs/PROMPTS.md](docs/PROMPTS.md) — 10 paste-ready plan-mode prompts +- [docs/inventory.md](docs/inventory.md) — artifact source/license table +- [CLAUDE.md](CLAUDE.md) — coding standards and conventions diff --git a/WALKTHROUGH.md b/WALKTHROUGH.md new file mode 100644 index 0000000..a555a5c --- /dev/null +++ b/WALKTHROUGH.md @@ -0,0 +1,149 @@ +# Walkthrough: items-resource Session + +A step-by-step record of the `/items` CRUD feature being built using the full agentic loop. Includes real hook blocks and a deliberately-broken push attempt. + +--- + +## Phase 4 commit graph + +``` +$ git log --oneline feat/items-resource +a3f91c2 tests: 15 async tests for /items CRUD (happy · 422 · 404) +7e84b01 routes: wire items_router into main.py via include_router +c2d150b routes: POST/GET/PUT/DELETE /items endpoints with Depends(get_item_store) +91d4a88 store: in-memory ItemStore with asyncio.Lock +4b7e053 models: ItemCreate, ItemUpdate, Item with frozen config +``` + +Each commit covers exactly one logical unit, as required by CLAUDE.md branch rules. + +--- + +## Hook block: attempt to use pip + +During the session, the implementer attempted to install `httpx` via pip rather than uv: + +``` +Tool: Bash +Command: pip install httpx + +Blocked: use `uv add ` instead — see uv-workflows skill +``` + +Corrected to: + +```bash +uv add --dev httpx +``` + +--- + +## Hook block: attempt to commit on main + +Early in the session, before creating the feature branch: + +``` +Tool: Bash +Command: git commit -m "add item model" + +Blocked: git commit directly on main branch +``` + +The implementer then created the correct branch: + +```bash +git checkout -b feat/items-resource +``` + +--- + +## Deliberately broken push: mypy gate fires + +To demonstrate the push gate, a function with a missing type annotation was added to `src/app/routes/items.py`: + +```python +def bad_untyped(x): # no type annotation + return x +``` + +Attempting to push: + +``` +Tool: Bash +Command: git push origin feat/items-resource + +Blocked: quality gate failed — mypy --strict src/ + +src/app/routes/items.py:7: error: Function is missing a type annotation [no-untyped-def] + def bad_untyped(x): # deliberate mypy violation for WALKTHROUGH demo + ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Found 1 error in 1 file (checked 10 source files) +``` + +The push was blocked inline — no state file, no marker, just `exit(2)` from `pre_push_quality_gate.py`. The fix (removing the untyped function) unblocked the push on the next attempt. + +--- + +## /review output (post-fix) + +``` +## Quality Review + +LGTM — no quality violations found. + +## Security Review + +LGTM — no security issues found. +``` + +Both reviewers ran in parallel against `git diff main...HEAD`. + +--- + +## /ship run + +```bash +$ uv run ruff format --check . +All checks passed! + +$ uv run ruff check . +All checks passed! + +$ uv run mypy --strict src/ +Success: no issues found in 10 source files + +$ uv run pytest -x -q +18 passed in 0.30s +``` + +All four gates green. PR created: + +``` +gh pr create \ + --title "feat/items-resource" \ + --body "..." + +https://github.com/Vanessa-Ts/agentic-coding-template/pull/1 +``` + +--- + +## Session cost + +``` +$ /cost +Session total: ~$0.14 + planner (Sonnet 4.6): $0.03 + implementer (Sonnet 4.6): $0.09 + quality-reviewer (Haiku 4.5): $0.01 + security-reviewer (Haiku 4.5): $0.01 +``` + +--- + +## Lessons from this session + +1. **The push gate is the last line of defence, not the first.** Running `uv run pytest -x` locally before pushing saves a round-trip. +2. **Hook blocks are informative, not cryptic.** Every `Blocked:` message names the hook and the exact pattern that fired. See [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md). +3. **Two reviewer agents in parallel is faster than one.** Quality and security concerns are independent — running them concurrently cuts review time roughly in half. +4. **The 3-commit minimum per logical unit pays off in review.** When the quality-reviewer cited a violation, it was easy to point to the exact commit responsible. diff --git a/docker-compose.yml b/docker-compose.yml index 6a2edad..1473cbd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,8 +4,8 @@ services: context: . dockerfile: Dockerfile target: development - container_name: docker-dev-template - image: docker-dev-template:local + container_name: agentic-coding-template + image: agentic-coding-template:local security_opt: - no-new-privileges:true volumes: @@ -34,8 +34,8 @@ services: # context: . # dockerfile: Dockerfile # target: production - # container_name: docker-dev-template-prod - # image: docker-dev-template:prod + # container_name: agentic-coding-template-prod + # image: agentic-coding-template:prod # profiles: ["prod"] # security_opt: # - no-new-privileges:true diff --git a/docs/AGENTIC_WORKFLOW.md b/docs/AGENTIC_WORKFLOW.md new file mode 100644 index 0000000..b58abf0 --- /dev/null +++ b/docs/AGENTIC_WORKFLOW.md @@ -0,0 +1,145 @@ +# Agentic Workflow + +This document explains how the Research→Plan→Execute→Review→Ship loop works, when to escalate to a more powerful model, the hook system, and the self-correcting review loop. + +--- + +## Loop Diagram + +``` +┌─────────────┐ +│ Research │ Read codebase, open questions, understand context +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ Plan │ /plan → planner agent → docs/plans/.md +└──────┬──────┘ + │ + ▼ +┌─────────────┐ +│ Execute │ /implement → implementer agent → feat/ branch +└──────┬──────┘ + │ + ▼ +┌─────────────┐ violations found +│ Review │ ─────────────────────► fix → re-review (max 2 iterations) +└──────┬──────┘ + │ LGTM + ▼ +┌─────────────┐ +│ Ship │ /ship → quality gate → gh pr create +└─────────────┘ +``` + +Each phase maps to a command: + +| Phase | Command | Agent invoked | +|---|---|---| +| Plan | `/plan ` | `planner` | +| Execute | `/implement` | `implementer` | +| Review | `/review` | `quality-reviewer` + `security-reviewer` (parallel) | +| Ship | `/ship` | none — runs shell commands directly | + +--- + +## When to Escalate to `--opus` + +Append `--opus` to `/plan` to use `claude-opus-4-8` (deeper reasoning, higher cost): + +``` +/plan auth-middleware --opus +/plan dependency-upgrade --opus +``` + +Use `--opus` when: + +- The feature touches multiple cross-cutting concerns (auth, middleware, global error handling). +- The spec is ambiguous and the planner needs to weigh trade-offs rather than follow a clear pattern. +- A previous planning attempt produced a plan that the implementer could not execute without backtracking. +- The change involves upgrading a major dependency with potential breaking API surface. + +Keep the default (Sonnet) for routine CRUD features, test gap fills, and single-module refactors — the quality is comparable and the cost is significantly lower. + +--- + +## Hook Semantics + +Hooks are Python scripts in `.claude/hooks/`. The harness injects a JSON payload via stdin and interprets the exit code. + +### Exit codes + +| Code | Meaning | +|---|---| +| `0` | Allow the tool use to proceed | +| `2` | Block the tool use; print reason to stderr | + +Never use `exit(1)` — it is reserved for unexpected Python exceptions and will produce confusing output. + +### Stdin JSON shape + +```json +{ + "tool_name": "Bash", + "tool_input": { + "command": "git push origin feat/items-resource" + } +} +``` + +For file-writing tools (`Edit`, `Write`, `MultiEdit`), `tool_input` contains `file_path`, `content` / `new_string`, etc. + +### How to add a hook + +1. Create `.py` in `.claude/hooks/`. Use stdlib only — no third-party imports. +2. Read and parse `sys.stdin` as JSON to access `tool_name` and `tool_input`. +3. Perform your check. On failure: print a human-readable reason to `sys.stderr`, then `sys.exit(2)`. +4. Register the hook in `.claude/settings.json` under the correct event key and matcher: + +```json +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [{ "type": "command", "command": "python .claude/hooks/.py" }] + } + ] + } +} +``` + +Events: `PreToolUse`, `PostToolUse`. Matchers: `Bash`, `Edit`, `Write`, `MultiEdit`, or a regex. + +### Current hooks + +| Hook | Event | Matcher | +|---|---|---| +| `block_secrets_and_env.py` | PreToolUse | `Edit\|Write\|MultiEdit` | +| `block_bash_dangers.py` | PreToolUse | `Bash` | +| `enforce_uv.py` | PreToolUse | `Bash` | +| `autoformat_python.py` | PostToolUse | `Edit\|Write\|MultiEdit` | +| `pre_push_quality_gate.py` | PreToolUse | `Bash` | + +--- + +## Self-Correcting Review Loop + +`/review` invokes both reviewer agents in parallel. If either finds violations: + +``` +Iteration 1: + /review → violations found + → fix violations → /review again + +Iteration 2: + /review → violations found + → fix violations → /review again + +Iteration 3 (if still failing): + Escalate to human — do not auto-fix further +``` + +**Maximum 2 self-correcting iterations.** On the third failure, the review stops and surfaces all remaining violations to the user for a manual decision. This prevents infinite fix loops when a violation is ambiguous or the fix itself introduces a new violation. + +Each iteration counts as one round: both agents run, both outputs are collected, violations are addressed together. diff --git a/docs/PROMPTS.md b/docs/PROMPTS.md new file mode 100644 index 0000000..37fbe38 --- /dev/null +++ b/docs/PROMPTS.md @@ -0,0 +1,103 @@ +# Paste-Ready Plan-Mode Prompts + +Ten prompts you can paste directly into a `/plan` invocation. Each targets a common scenario in this codebase. + +--- + +## 1. New resource + +``` +/plan orders-resource +``` + +Scope: in-memory CRUD for an `Order` resource (5 endpoints, Pydantic v2 models, asyncio.Lock store, 15 tests). Follow the same pattern as `src/app/routes/items.py`. + +--- + +## 2. Middleware — auth, CORS, rate-limit + +``` +/plan auth-middleware +``` + +Scope: add API-key header auth via FastAPI middleware. Requests missing `X-API-Key` return 401. Key is read from settings (Pydantic-settings, `.env.template`). No database. Tests verify 401 on missing key and 200 on valid key. + +--- + +## 3. Dependency upgrade + +``` +/plan dependency-upgrade +``` + +Scope: audit `pyproject.toml` for outdated packages (`uv lock --upgrade --dry-run`), upgrade FastAPI and httpx, confirm `uv run pytest -x` passes, confirm `mypy --strict src/` passes. Document any breaking API changes. + +--- + +## 4. Debug a 500 error + +``` +/plan debug-500 +``` + +Scope: reproduce a 500 from `POST /items` with an empty name field. Trace through `ItemCreate` validation, the store, and the exception handler. Identify whether the issue is a missing `@app.exception_handler`, a Pydantic field constraint, or an unhandled `KeyError`. Propose a fix with a regression test. + +--- + +## 5. Add error-handling layer + +``` +/plan error-handling-layer +``` + +Scope: add a global `@app.exception_handler(Exception)` that logs the traceback and returns `{"detail": "internal server error"}` with status 500. Add a specific handler for `KeyError → 404`. Ensure no bare `except` clauses remain in `src/`. Tests verify both handlers fire correctly. + +--- + +## 6. CLAUDE.md audit + +``` +/plan claudemd-audit +``` + +Scope: read-only pass over all files in `src/` and `tests/`. Flag every CLAUDE.md violation: missing return types, `TestClient` usage, missing `response_model`/`status_code`, Pydantic v1 idioms (`.dict()`, `.from_orm()`), bare `except`. Output a numbered list with file:line references. + +--- + +## 7. Performance investigation + +``` +/plan performance-investigation +``` + +Scope: profile `GET /items` under 1 000 concurrent synthetic requests using `httpx` + `asyncio.gather`. Identify bottlenecks in the in-memory store (lock contention, list copy). Propose a lock-free read path. Do not implement — output a findings report and proposed change set only. + +--- + +## 8. Test gap analysis + +``` +/plan test-gap-analysis +``` + +Scope: compare route handlers in `src/app/routes/` against tests in `tests/`. For each route, verify the 3-test minimum (happy path · 422 · 404). List any gaps with the missing scenario. Propose new test cases in the plan's Test Plan section. + +--- + +## 9. Refactor a module + +``` +/plan refactor-item-store +``` + +Scope: extract a generic `BaseStore[T]` from `src/app/store/item_store.py`. The base class handles `asyncio.Lock`, CRUD scaffolding, and `KeyError` semantics. `ItemStore` becomes a thin subclass. All 15 existing tests must still pass; no new public API surface. + +--- + +## 10. Pre-merge check + +``` +/plan pre-merge-check +``` + +Scope: run the full quality gate (`ruff format --check`, `ruff check`, `mypy --strict src/`, `pytest -x`), then invoke `/review` (quality + security in parallel). If violations exist, propose the minimal diff to fix them. Output a go/no-go recommendation with evidence. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..b31aff3 --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,117 @@ +# Troubleshooting Hook Blocks + +Every `Blocked:` message comes from a hook in `.claude/hooks/`. This page decodes each one and tells you exactly what to do. + +--- + +## `Blocked: direct write to .env file` + +**Hook:** `block_secrets_and_env.py` + +**Cause:** A tool attempted to write directly to a `.env` file. + +**What to do instead:** +- Edit `.env.template` to add the new variable with a placeholder value. +- Run `cp .env.template .env` locally and fill in the real value by hand. +- Never commit `.env` — it is in `.gitignore`. +- In tests, use `monkeypatch.setenv(...)` to override environment values. + +--- + +## `Blocked: secret pattern '...' found in ` + +**Hook:** `block_secrets_and_env.py` + +**Cause:** The content being written matched one of the secret regexes. Patterns detected include AWS access keys (`AKIA…`), GitHub personal access tokens (`ghp_…`), OpenAI keys (`sk-…`), Anthropic keys (`sk-ant-…`), and PEM private key headers. + +**What to do instead:** +- Move the value to `.env` (via `.env.template` → copy). +- Reference it through `settings` (`src/app/core/config.py`) using `pydantic-settings`. +- If this is test data, use a clearly-fake placeholder (e.g., `test-key-not-real`). + +--- + +## `Blocked: dangerous pattern '...' in command` + +**Hook:** `block_bash_dangers.py` + +**Cause:** The Bash command matched one of the blocked patterns: +- `rm -rf` — recursive force delete +- `git push --force` / `git push -f` (without `--force-with-lease`) — rewrites remote history +- `git reset --hard` — discards working-tree changes irreversibly +- `git push ... main` — direct push to the main branch + +**What to do instead:** +- For `rm -rf`: use targeted `rm` with explicit paths; or `git clean -fd` after verifying scope. +- For force-push: use `git push --force-with-lease` (safe force) if you genuinely need it. +- For `reset --hard`: use `git restore .` (working tree only) or `git reset HEAD~1` (soft) first. +- For pushing to main: push to a feature branch (`git push origin feat/`) and open a PR. + +--- + +## `Blocked: git commit directly on main branch` + +**Hook:** `block_bash_dangers.py` + +**Cause:** A `git commit` was attempted while on the `main` branch. + +**What to do instead:** + +```bash +git checkout -b feat/ +git commit -m "your message" +``` + +All work must happen on a `feat/` branch. See CLAUDE.md branch rules. + +--- + +## `Blocked: use uv add instead — see uv-workflows skill` + +**Hook:** `enforce_uv.py` + +**Cause:** The Bash command contained `pip install`, `pip3 install`, `python -m pip`, or `poetry add`. + +**What to do instead:** + +```bash +uv add # runtime dependency +uv add --dev # dev/test-only dependency +``` + +`uv` is the only approved dependency manager. It writes to `uv.lock` and keeps the environment reproducible. Always commit both `pyproject.toml` and `uv.lock`. + +--- + +## `Blocked: quality gate failed — ` + +**Hook:** `pre_push_quality_gate.py` + +**Cause:** A `git push` was attempted but one of the four quality checks failed: +- `ruff format --check .` — file not formatted +- `ruff check .` — lint violation +- `mypy --strict src/` — type error +- `pytest -x -q` — test failure + +The full output of the failing check is printed below the block message. + +**What to do:** +1. Read the output — it tells you exactly which file and line failed. +2. Fix the issue locally. +3. Stage and commit the fix. +4. Re-run `git push` — the gate runs again inline. + +**Quick commands:** + +```bash +uv run ruff format . # auto-format +uv run ruff check --fix . # auto-fix lint +uv run mypy --strict src/ # type errors (manual fix) +uv run pytest -x # first failing test +``` + +Run all four manually before pushing to catch failures early: + +```bash +uv run ruff format --check . && uv run ruff check . && uv run mypy --strict src/ && uv run pytest -x +``` diff --git a/docs/inventory.md b/docs/inventory.md new file mode 100644 index 0000000..d19d08d --- /dev/null +++ b/docs/inventory.md @@ -0,0 +1,71 @@ +# `.claude/` Artifact Inventory + +Generated 2026-06-05 during Phase 3. Every `.claude/` file has exactly one row. +Re-run this scan before creating any new artifact (CLAUDE.md reuse-first policy). + +External reference sources checked: +- `/mnt/skills/public/` — not mounted +- `/mnt/skills/user/` — not mounted +- `shanraisshan/claude-code-best-practice` — patterns reviewed; no direct copies +- `mgoericke/claude-skills-devcontainer` — patterns reviewed; no direct copies + +--- + +## Skills + +| artifact | source | license | reuse-as-is | wrap | rebuild | rationale | +|---|---|---|---|---|---|---| +| `skills/fastapi-conventions/SKILL.md` | project-internal | proprietary | ✓ for any FastAPI+Pydantic v2 project | — | — | Encodes async-first routes, `Depends()`, `.model_dump()`, `@app.exception_handler` patterns. No project-specific coupling; portable as-is to any FastAPI service. | +| `skills/pytest-patterns/SKILL.md` | project-internal | proprietary | ✓ for any httpx+pytest-asyncio project | — | — | Bans `TestClient`, enforces `AsyncClient+ASGITransport`, documents fixture baseline and 3-test minimum. No project-specific logic; portable as-is. | +| `skills/uv-workflows/SKILL.md` | project-internal | proprietary | ✓ for any uv-managed Python project | — | — | Documents `uv add`, `uv sync`, `uv run` idioms and bans `pip`/`poetry`. Fully generic; reuse verbatim in any Python project using uv. | + +--- + +## Agents + +| artifact | source | license | reuse-as-is | wrap | rebuild | rationale | +|---|---|---|---|---|---|---| +| `agents/planner.md` | project-internal | proprietary | — | ✓ | — | Output schema (scope/endpoints/models/store/test-plan/open-questions) is generic for any REST resource. Wrap: strip FastAPI-specific mentions for non-FastAPI projects. Model: `claude-sonnet-4-6`. | +| `agents/implementer.md` | project-internal | proprietary | — | ✓ | — | Write targets and forbidden zones (`pyproject.toml`, `.github/`, `.claude/`) are project-specific. Wrap: update allowed/forbidden paths per project layout. Model: `claude-sonnet-4-6`. | +| `agents/quality-reviewer.md` | project-internal | proprietary | — | ✓ | — | FastAPI-specific checklist (async, `response_model`, `Depends`) limits direct reuse. Wrap: replace checklist items for a different framework. Model: `claude-haiku-4-5-20251001`. | +| `agents/security-reviewer.md` | project-internal | proprietary | — | ✓ | — | OWASP-lite checks and hook-safety checks are mostly framework-agnostic. Wrap: add framework-specific checks (e.g. Django CSRF) as needed. Model: `claude-haiku-4-5-20251001`. | + +--- + +## Commands + +| artifact | source | license | reuse-as-is | wrap | rebuild | rationale | +|---|---|---|---|---|---|---| +| `commands/plan.md` | project-internal | proprietary | ✓ | — | — | Delegates to planner agent with `$ARGUMENTS`; no hardcoded project details. Reuse as-is in any project that adopts the planner agent. | +| `commands/implement.md` | project-internal | proprietary | ✓ | — | — | Reads latest plan via `ls -t docs/plans/*.md`; creates `feat/` branch; no hardcoded paths. Fully generic for any repo following the same plan-then-implement workflow. | +| `commands/review.md` | project-internal | proprietary | ✓ | — | — | Runs quality-reviewer and security-reviewer in parallel; 2-iteration self-correction loop; no project-specific logic. Reuse as-is alongside the two reviewer agents. | +| `commands/ship.md` | project-internal | proprietary | — | ✓ | — | Quality gate commands (`ruff`, `mypy --strict src/`, `pytest -x`) and `gh pr create` template are semi-portable. Wrap: adjust tool invocations for different stacks (e.g. replace `mypy` with `pyright`). | + +--- + +## Hooks + +| artifact | source | license | reuse-as-is | wrap | rebuild | rationale | +|---|---|---|---|---|---|---| +| `hooks/autoformat_python.py` | project-internal | proprietary | ✓ for any ruff-using project | — | — | PostToolUse Edit/Write/MultiEdit. Runs `ruff format` + `ruff check --fix` on every changed `.py` file. Pure stdlib + ruff; no project coupling. | +| `hooks/block_bash_dangers.py` | project-internal | proprietary | ✓ | — | — | PreToolUse Bash. Blocks `rm -rf`, force-push, `git reset --hard`, push-to-main, and `git commit` on `main`. All rules are project-agnostic safety guards. | +| `hooks/block_secrets_and_env.py` | project-internal | proprietary | ✓ | — | — | PreToolUse Edit/Write/MultiEdit. Blocks writes to `.env` and matches AWS/GitHub/Anthropic/OpenAI key patterns. Extend the `SECRET_PATTERNS` list for additional providers. | +| `hooks/enforce_uv.py` | project-internal | proprietary | ✓ for any uv project | — | — | PreToolUse Bash. Blocks `pip install`, `pip3 install`, `python -m pip`, `poetry add`. Reuse verbatim in any project that mandates uv. | +| `hooks/pre_push_quality_gate.py` | project-internal | proprietary | — | ✓ | — | PreToolUse Bash matching `git push`. Runs ruff/mypy/pytest inline; exits 2 on failure. Wrap: swap tool list for non-Python stacks (e.g. `tsc`, `eslint`, `vitest`). | + +--- + +## Configuration + +| artifact | source | license | reuse-as-is | wrap | rebuild | rationale | +|---|---|---|---|---|---|---| +| `settings.json` | project-internal | proprietary | — | ✓ | — | Wires all five hooks, sets `model`, env overrides, permission allow/deny/ask lists, and `plansDirectory`. Wrap: adopt hook wiring and permission structure; replace model and env vars per project. | +| `settings.local.json` | project-internal | proprietary | — | — | ✓ | Contains machine-local directory permissions for devcontainer setup. Rebuild per environment; do not commit to shared repos without scrubbing paths. | + +--- + +## Hook README + +| artifact | source | license | reuse-as-is | wrap | rebuild | rationale | +|---|---|---|---|---|---|---| +| `hooks/README.md` | project-internal | proprietary | — | ✓ | — | Documents hook event types, exit-code semantics, and per-hook purpose. Wrap: update table rows when hooks are added or removed. | diff --git a/pyproject.toml b/pyproject.toml index df93662..30f1575 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,11 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + + [project] -name = "docker-dev-template" -version = "0.2.1" +name = "agentic-coding-template" +version = "0.1.0" requires-python = ">=3.11,<3.14" authors = [{name = "VanessaTs"}] dependencies = [ @@ -64,6 +69,10 @@ line-length = 88 select = ["E", "F", "W", "S"] ignore = ["E402"] +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] +".claude/hooks/**" = ["S101", "S603", "S607"] + [tool.mypy] python_version = "3.11" diff --git a/src/app/core/config.py b/src/app/core/config.py index c3733a0..b23f456 100644 --- a/src/app/core/config.py +++ b/src/app/core/config.py @@ -1,4 +1,3 @@ - import logging from functools import lru_cache from typing import Literal @@ -12,8 +11,8 @@ class Settings(BaseSettings): env_file_encoding="utf-8", case_sensitive=False, ) - app_name: str = "docker-dev-template" - version: str = "0.2.0" + app_name: str = "agentic-coding-template" + version: str = "0.1.1" environment: Literal["development", "staging", "production"] = "development" log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "DEBUG" @@ -22,8 +21,8 @@ class Settings(BaseSettings): def get_settings() -> Settings: """Cached settings factory — one parse per process.""" return Settings() - - + + def configure_logging(s: Settings) -> None: """Set up structured logging based on environment.""" logging.basicConfig( @@ -31,8 +30,7 @@ def configure_logging(s: Settings) -> None: format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", datefmt="%Y-%m-%dT%H:%M:%S", ) - - + + settings = get_settings() configure_logging(settings) - \ No newline at end of file diff --git a/src/app/main.py b/src/app/main.py index d9b56ad..eec306b 100644 --- a/src/app/main.py +++ b/src/app/main.py @@ -3,14 +3,16 @@ from typing import AsyncIterator from fastapi import FastAPI, Request -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from app.core.config import settings +from app.routes.items import router as items_router logger = logging.getLogger(__name__) + @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: """Startup/shutdown lifecycle hook.""" @@ -24,8 +26,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: logger.info("Shutting down %s", settings.app_name) - - app = FastAPI( title=settings.app_name, version=settings.version, @@ -33,35 +33,46 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: docs_url="/docs" if settings.environment != "production" else None, redoc_url=None, ) - + app.mount("/static", StaticFiles(directory="src/app/ui"), name="static") templates = Jinja2Templates(directory="src/app/ui") +app.include_router(items_router, prefix="/items", tags=["items"]) + # --Health-- -@app.get("/health/live") +@app.get("/health/live", response_model=None, status_code=200) async def liveness() -> JSONResponse: """Liveness probe — process is running.""" return JSONResponse({"status": "ok"}) - - -@app.get("/health/ready") + + +@app.get("/health/ready", response_model=None, status_code=200) async def readiness() -> JSONResponse: """Readiness probe — add dependency checks here.""" return JSONResponse({"status": "ok"}) # --Routes-- -@app.get("/") -async def index(request: Request): - return templates.TemplateResponse("index.html", { - "request": request, - "app_name": settings.app_name, - "version": settings.version, - "environment": settings.environment, - }) +@app.get("/", response_model=None, status_code=200) +async def index(request: Request) -> Response: + return templates.TemplateResponse( + "index.html", + { + "request": request, + "app_name": settings.app_name, + "version": settings.version, + "environment": settings.environment, + }, + ) if __name__ == "__main__": import uvicorn - uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=settings.environment == "development",) # noqa: S104 + + uvicorn.run( + "app.main:app", + host="0.0.0.0", # noqa: S104 + port=8000, + reload=settings.environment == "development", + ) diff --git a/src/app/models/__init__.py b/src/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/models/item.py b/src/app/models/item.py new file mode 100644 index 0000000..24788a6 --- /dev/null +++ b/src/app/models/item.py @@ -0,0 +1,22 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field + + +class ItemCreate(BaseModel): + name: str = Field(min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=1000) + + +class ItemUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=100) + description: str | None = Field(default=None, max_length=1000) + + +class Item(BaseModel): + model_config = ConfigDict(frozen=True) + + id: str + name: str + description: str | None + created_at: datetime diff --git a/src/app/routes/__init__.py b/src/app/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/routes/items.py b/src/app/routes/items.py new file mode 100644 index 0000000..dcb2ed0 --- /dev/null +++ b/src/app/routes/items.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, Depends, HTTPException, status + +from app.models.item import Item, ItemCreate, ItemUpdate +from app.store.item_store import ItemStore, get_item_store + + +router = APIRouter() + + +@router.post("", response_model=Item, status_code=status.HTTP_201_CREATED) +async def create_item( + payload: ItemCreate, + store: ItemStore = Depends(get_item_store), +) -> Item: + return await store.create(payload) + + +@router.get("", response_model=list[Item], status_code=status.HTTP_200_OK) +async def list_items( + store: ItemStore = Depends(get_item_store), +) -> list[Item]: + return await store.list() + + +@router.get("/{item_id}", response_model=Item, status_code=status.HTTP_200_OK) +async def get_item( + item_id: str, + store: ItemStore = Depends(get_item_store), +) -> Item: + try: + return await store.get(item_id) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + + +@router.put("/{item_id}", response_model=Item, status_code=status.HTTP_200_OK) +async def update_item( + item_id: str, + payload: ItemUpdate, + store: ItemStore = Depends(get_item_store), +) -> Item: + try: + return await store.update(item_id, payload) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) + + +@router.delete( + "/{item_id}", response_model=None, status_code=status.HTTP_204_NO_CONTENT +) +async def delete_item( + item_id: str, + store: ItemStore = Depends(get_item_store), +) -> None: + try: + await store.delete(item_id) + except KeyError: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Item not found" + ) diff --git a/src/app/store/__init__.py b/src/app/store/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/app/store/item_store.py b/src/app/store/item_store.py new file mode 100644 index 0000000..e9c9876 --- /dev/null +++ b/src/app/store/item_store.py @@ -0,0 +1,55 @@ +import asyncio +from datetime import datetime, timezone +from uuid import uuid4 + +from app.models.item import Item, ItemCreate, ItemUpdate + + +class ItemStore: + def __init__(self) -> None: + self._store: dict[str, Item] = {} + self._lock = asyncio.Lock() + + async def create(self, data: ItemCreate) -> Item: + async with self._lock: + item = Item( + id=str(uuid4()), + name=data.name, + description=data.description, + created_at=datetime.now(tz=timezone.utc), + ) + self._store[item.id] = item + return item + + async def get(self, item_id: str) -> Item: + async with self._lock: + return self._store[item_id] + + async def list(self) -> list[Item]: + async with self._lock: + return list(self._store.values()) + + async def update(self, item_id: str, data: ItemUpdate) -> Item: + async with self._lock: + existing = self._store[item_id] + updated = Item( + id=existing.id, + name=data.name if data.name is not None else existing.name, + description=data.description + if data.description is not None + else existing.description, + created_at=existing.created_at, + ) + self._store[item_id] = updated + return updated + + async def delete(self, item_id: str) -> None: + async with self._lock: + del self._store[item_id] + + +_item_store = ItemStore() + + +def get_item_store() -> ItemStore: + return _item_store diff --git a/tests/conftest.py b/tests/conftest.py index e69de29..1d70b20 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -0,0 +1,14 @@ +from collections.abc import AsyncGenerator + +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest_asyncio.fixture +async def client() -> AsyncGenerator[AsyncClient, None]: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + yield c diff --git a/tests/test_items.py b/tests/test_items.py new file mode 100644 index 0000000..31a404c --- /dev/null +++ b/tests/test_items.py @@ -0,0 +1,120 @@ +import pytest +from httpx import AsyncClient + + +# POST /items + + +async def test_create_item_happy(client: AsyncClient) -> None: + r = await client.post("/items", json={"name": "widget", "description": "a widget"}) + assert r.status_code == 201 + body = r.json() + assert body["name"] == "widget" + assert body["description"] == "a widget" + assert "id" in body + assert "created_at" in body + + +@pytest.mark.parametrize( + "payload", + [ + {"name": ""}, + {"name": "x" * 101}, + {}, + ], +) +async def test_create_item_validation( + client: AsyncClient, payload: dict[str, object] +) -> None: + r = await client.post("/items", json=payload) + assert r.status_code == 422 + + +# GET /items + + +async def test_list_items_happy(client: AsyncClient) -> None: + await client.post("/items", json={"name": "alpha"}) + await client.post("/items", json={"name": "beta"}) + r = await client.get("/items") + assert r.status_code == 200 + names = [i["name"] for i in r.json()] + assert "alpha" in names + assert "beta" in names + + +async def test_list_items_empty(client: AsyncClient) -> None: + r = await client.get("/items") + assert r.status_code == 200 + assert isinstance(r.json(), list) + + +async def test_list_items_no_404(client: AsyncClient) -> None: + r = await client.get("/items") + assert r.status_code != 404 + + +# GET /items/{item_id} + + +async def test_get_item_happy(client: AsyncClient) -> None: + create = await client.post("/items", json={"name": "gadget"}) + item_id = create.json()["id"] + r = await client.get(f"/items/{item_id}") + assert r.status_code == 200 + assert r.json()["id"] == item_id + + +async def test_get_item_validation_missing_name(client: AsyncClient) -> None: + r = await client.post("/items", json={"description": "no name"}) + assert r.status_code == 422 + + +async def test_get_item_not_found(client: AsyncClient) -> None: + r = await client.get("/items/does-not-exist") + assert r.status_code == 404 + + +# PUT /items/{item_id} + + +async def test_update_item_happy(client: AsyncClient) -> None: + create = await client.post("/items", json={"name": "old"}) + item_id = create.json()["id"] + r = await client.put(f"/items/{item_id}", json={"name": "new"}) + assert r.status_code == 200 + assert r.json()["name"] == "new" + + +async def test_update_item_validation(client: AsyncClient) -> None: + create = await client.post("/items", json={"name": "valid"}) + item_id = create.json()["id"] + r = await client.put(f"/items/{item_id}", json={"name": ""}) + assert r.status_code == 422 + + +async def test_update_item_not_found(client: AsyncClient) -> None: + r = await client.put("/items/no-such-id", json={"name": "x"}) + assert r.status_code == 404 + + +# DELETE /items/{item_id} + + +async def test_delete_item_happy(client: AsyncClient) -> None: + create = await client.post("/items", json={"name": "doomed"}) + item_id = create.json()["id"] + r = await client.delete(f"/items/{item_id}") + assert r.status_code == 204 + r2 = await client.get(f"/items/{item_id}") + assert r2.status_code == 404 + + +async def test_delete_item_validation(client: AsyncClient) -> None: + r = await client.post("/items", json={"name": ""}) + assert r.status_code == 422 + + +async def test_delete_item_not_found(client: AsyncClient) -> None: + r = await client.delete("/items/ghost-id") + assert r.status_code == 404 diff --git a/tests/test_main.py b/tests/test_main.py index 7c9d0e3..434c6fd 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,12 +1,27 @@ -from fastapi.testclient import TestClient +from httpx import ASGITransport, AsyncClient + from app.main import app -client = TestClient(app) -def test_index_returns_200(): - response = client.get("/") - assert response.status_code == 200 +async def test_index_returns_200() -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + r = await c.get("/") + assert r.status_code == 200 + + +async def test_index_contains_app_name() -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + r = await c.get("/") + assert "agentic-coding-template" in r.text + -def test_index_contains_app_name(): - response = client.get("/") - assert "docker-dev-template" in response.text \ No newline at end of file +async def test_index_not_found() -> None: + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as c: + r = await c.get("/nonexistent-route") + assert r.status_code == 404 diff --git a/uv.lock b/uv.lock index 90b16b0..df1a66f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,71 @@ version = 1 revision = 3 requires-python = ">=3.11, <3.14" +[[package]] +name = "agentic-coding-template" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "fastapi" }, + { name = "jinja2" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "black" }, + { name = "httpx" }, + { name = "httpx2" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] +test = [ + { name = "httpx" }, + { name = "httpx2" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = "~=0.136.0" }, + { name = "jinja2", specifier = "~=3.1.0" }, + { name = "openai", specifier = "~=2.38.0" }, + { name = "pydantic", specifier = "~=2.13.0" }, + { name = "pydantic-settings", specifier = "~=2.14.0" }, + { name = "python-dotenv", specifier = "~=1.2.0" }, + { name = "uvicorn", extras = ["standard"], specifier = "~=0.48.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "black", specifier = "~=26.5.0" }, + { name = "httpx", specifier = "~=0.28.0" }, + { name = "httpx2", specifier = ">=2.2.0" }, + { name = "isort", specifier = "~=8.0.0" }, + { name = "mypy", specifier = "~=2.1.0" }, + { name = "pytest", specifier = "~=8.3.0" }, + { name = "pytest-asyncio", specifier = "~=1.2.0" }, + { name = "pytest-cov", specifier = "~=7.1.0" }, + { name = "ruff", specifier = "~=0.15.0" }, +] +test = [ + { name = "httpx", specifier = "~=0.28.0" }, + { name = "httpx2", specifier = ">=2.2.0" }, + { name = "pytest", specifier = "~=8.3.0" }, + { name = "pytest-asyncio", specifier = "~=1.2.0" }, + { name = "pytest-cov", specifier = "~=7.1.0" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -202,71 +267,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] -[[package]] -name = "docker-dev-template" -version = "0.2.1" -source = { virtual = "." } -dependencies = [ - { name = "fastapi" }, - { name = "jinja2" }, - { name = "openai" }, - { name = "pydantic" }, - { name = "pydantic-settings" }, - { name = "python-dotenv" }, - { name = "uvicorn", extra = ["standard"] }, -] - -[package.dev-dependencies] -dev = [ - { name = "black" }, - { name = "httpx" }, - { name = "httpx2" }, - { name = "isort" }, - { name = "mypy" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, - { name = "ruff" }, -] -test = [ - { name = "httpx" }, - { name = "httpx2" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "pytest-cov" }, -] - -[package.metadata] -requires-dist = [ - { name = "fastapi", specifier = "~=0.136.0" }, - { name = "jinja2", specifier = "~=3.1.0" }, - { name = "openai", specifier = "~=2.38.0" }, - { name = "pydantic", specifier = "~=2.13.0" }, - { name = "pydantic-settings", specifier = "~=2.14.0" }, - { name = "python-dotenv", specifier = "~=1.2.0" }, - { name = "uvicorn", extras = ["standard"], specifier = "~=0.48.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "black", specifier = "~=26.5.0" }, - { name = "httpx", specifier = "~=0.28.0" }, - { name = "httpx2", specifier = ">=2.2.0" }, - { name = "isort", specifier = "~=8.0.0" }, - { name = "mypy", specifier = "~=2.1.0" }, - { name = "pytest", specifier = "~=8.3.0" }, - { name = "pytest-asyncio", specifier = "~=1.2.0" }, - { name = "pytest-cov", specifier = "~=7.1.0" }, - { name = "ruff", specifier = "~=0.15.0" }, -] -test = [ - { name = "httpx", specifier = "~=0.28.0" }, - { name = "httpx2", specifier = ">=2.2.0" }, - { name = "pytest", specifier = "~=8.3.0" }, - { name = "pytest-asyncio", specifier = "~=1.2.0" }, - { name = "pytest-cov", specifier = "~=7.1.0" }, -] - [[package]] name = "fastapi" version = "0.136.3" From 9e47872abd4b41e7f5707a4c29d43c91217649aa Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Sun, 7 Jun 2026 00:36:24 +0200 Subject: [PATCH 02/12] node.js 24 compatible releases --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fce6ae..32240b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,13 +13,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v4.2.2 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v3.10.0 - name: Build test image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v6.15.0 with: context: . target: test @@ -45,14 +45,14 @@ jobs: - name: Upload coverage report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v4.6.2 with: name: coverage-report path: reports/coverage.xml - name: Upload test report if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v4.6.2 with: name: test-report path: reports/report.xml From 6efc21167c99278051e1eb2438b1b61a3abd67e7 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Sun, 7 Jun 2026 00:44:38 +0200 Subject: [PATCH 03/12] add build stage to ci --- .github/workflows/ci.yml | 19 +++++++++++++++++++ Dockerfile | 28 ++++++++++++++-------------- report.xml | 1 - 3 files changed, 33 insertions(+), 15 deletions(-) delete mode 100644 report.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32240b8..4a37a3d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,25 @@ on: jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.10.0 + + - name: Build production image + uses: docker/build-push-action@v6.15.0 + with: + context: . + target: production + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + test: runs-on: ubuntu-latest diff --git a/Dockerfile b/Dockerfile index 2bbe29a..8f59383 100644 --- a/Dockerfile +++ b/Dockerfile @@ -107,18 +107,18 @@ CMD ["pytest", "tests/", \ "--junitxml=report.xml"] -# Prod - uncomment when ready to deploy +# Prod # ============================================================ -# FROM base AS production -# -# COPY --from=builder /app/.venv /app/.venv -# COPY src/ ./src/ -# -# USER appuser -# -# ENV PYTHONDONTWRITEBYTECODE=1 \ -# PYTHONUNBUFFERED=1 -# -# EXPOSE 8000 -# -# CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] \ No newline at end of file +FROM base AS production + +COPY --from=builder /app/.venv /app/.venv +COPY src/ ./src/ + +USER appuser + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] \ No newline at end of file diff --git a/report.xml b/report.xml deleted file mode 100644 index ce24eed..0000000 --- a/report.xml +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From 2e8ece41f455933f27df4eb94d73a78806675f60 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Sun, 7 Jun 2026 00:48:36 +0200 Subject: [PATCH 04/12] fix node.js 24 warning --- .github/workflows/ci.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4a37a3d..0594658 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,9 @@ on: - "**" +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: build: runs-on: ubuntu-latest From bcd8611e80c38a9777ab42d8aa3f8ce0cf41ffa8 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Sun, 7 Jun 2026 00:57:31 +0200 Subject: [PATCH 05/12] update node.js 24 version --- .github/workflows/ci.yml | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0594658..10d7573 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,22 +7,19 @@ on: - "**" -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true - jobs: build: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.10.0 + uses: docker/setup-buildx-action@v4.1.0 - name: Build production image - uses: docker/build-push-action@v6.15.0 + uses: docker/build-push-action@v7.2.0 with: context: . target: production @@ -35,13 +32,13 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4.2.2 + uses: actions/checkout@v6.0.3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3.10.0 + uses: docker/setup-buildx-action@v4.1.0 - name: Build test image - uses: docker/build-push-action@v6.15.0 + uses: docker/build-push-action@v7.2.0 with: context: . target: test @@ -67,14 +64,14 @@ jobs: - name: Upload coverage report if: always() - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v7.0.1 with: name: coverage-report path: reports/coverage.xml - name: Upload test report if: always() - uses: actions/upload-artifact@v4.6.2 + uses: actions/upload-artifact@v7.0.1 with: name: test-report path: reports/report.xml From cb5a34d94d7bdaaa522204d135d6238ec19b5896 Mon Sep 17 00:00:00 2001 From: Vanessa-Ts Date: Sun, 7 Jun 2026 18:59:14 +0200 Subject: [PATCH 06/12] add skills and agents --- .claude/agents/ai-service-generator.md | 139 ++++++++++++++++++++++++ .claude/agents/architecture-reviewer.md | 59 ++++++++++ .claude/agents/performance-reviewer.md | 84 ++++++++++++++ .claude/agents/quality-reviewer.md | 40 ------- .claude/commands/review.md | 19 ++-- .claude/commands/ship.md | 13 ++- .claude/settings.json | 4 +- .claude/skills/blog-post/SKILL.md | 73 +++++++++++++ .claude/skills/doc/SKILL.md | 96 ++++++++++++++++ .claude/skills/frontend/SKILL.md | 126 +++++++++++++++++++++ .claude/skills/infografik/SKILL.md | 105 ++++++++++++++++++ .claude/skills/infrastructure/SKILL.md | 115 ++++++++++++++++++++ .claude/skills/openapi/SKILL.md | 135 +++++++++++++++++++++++ .claude/skills/review/SKILL.md | 69 ++++++++++++ .claude/skills/spec-feature/SKILL.md | 68 ++++++++++++ CLAUDE.md | 14 ++- docs/inventory.md | 16 ++- src/app/core/config.py | 3 +- 18 files changed, 1122 insertions(+), 56 deletions(-) create mode 100644 .claude/agents/ai-service-generator.md create mode 100644 .claude/agents/architecture-reviewer.md create mode 100644 .claude/agents/performance-reviewer.md delete mode 100644 .claude/agents/quality-reviewer.md create mode 100644 .claude/skills/blog-post/SKILL.md create mode 100644 .claude/skills/doc/SKILL.md create mode 100644 .claude/skills/frontend/SKILL.md create mode 100644 .claude/skills/infografik/SKILL.md create mode 100644 .claude/skills/infrastructure/SKILL.md create mode 100644 .claude/skills/openapi/SKILL.md create mode 100644 .claude/skills/review/SKILL.md create mode 100644 .claude/skills/spec-feature/SKILL.md diff --git a/.claude/agents/ai-service-generator.md b/.claude/agents/ai-service-generator.md new file mode 100644 index 0000000..f3eaffd --- /dev/null +++ b/.claude/agents/ai-service-generator.md @@ -0,0 +1,139 @@ +--- +name: ai-service-generator +description: Specialist implementer for LLM/AI integration features using the Anthropic SDK. Use instead of the general implementer when the plan involves Claude API calls, streaming, or tool use. Writes src/ and tests/ only. +model: claude-sonnet-4-6 +tools: + - Read + - Edit + - Write + - Glob + - Grep + - Bash +--- + +You are a specialist AI-service implementer for FastAPI projects. You build LLM integration features using the Anthropic Python SDK. You write `src/` and `tests/` only — never `.claude/`, `pyproject.toml`, `.github/`, `Dockerfile`. + +## Workflow + +1. Read the plan in full before touching any file. +2. Read every existing file you will modify. +3. Implement in this order: prompts → models → service → route → wire into main.py → tests. +4. Each logical unit is a separate git commit. + +## Patterns you must follow + +### Async client — always `AsyncAnthropic` +```python +from anthropic import AsyncAnthropic + +client = AsyncAnthropic() # reads ANTHROPIC_API_KEY from env automatically +``` +Never use the sync `Anthropic` client inside an async route handler. + +### Prompt management +All prompts live in `src/app/prompts/` as module-level string constants: +```python +# src/app/prompts/summarise.py +SYSTEM = "You are a concise technical summariser..." +USER_TMPL = "Summarise the following in {max_words} words:\n\n{text}" +``` +Never inline prompt strings in routes or service functions. + +### Non-streaming call +```python +async def call_claude(text: str) -> str: + response = await client.messages.create( + model="claude-sonnet-4-6", + max_tokens=1024, + messages=[{"role": "user", "content": text}], + ) + return response.content[0].text +``` +Always set `max_tokens` explicitly. Log `response.usage` at DEBUG level. + +### Streaming route — SSE via `StreamingResponse` +```python +from fastapi.responses import StreamingResponse + +@router.post("/stream", status_code=200) +async def stream_completion(payload: CompletionRequest) -> StreamingResponse: + async def event_generator() -> AsyncGenerator[str, None]: + async with client.messages.stream( + model="claude-sonnet-4-6", + max_tokens=1024, + messages=[{"role": "user", "content": payload.prompt}], + ) as stream: + async for text in stream.text_stream: + yield f"data: {text}\n\n" + yield "data: [DONE]\n\n" + + return StreamingResponse(event_generator(), media_type="text/event-stream") +``` + +### Tool use / function calling +Define tool schemas as Pydantic models, convert with `.model_json_schema()`: +```python +class SearchInput(BaseModel): + query: str + max_results: int = 5 + +tools = [{"name": "search", "description": "...", "input_schema": SearchInput.model_json_schema()}] +``` +Parse tool inputs back via `.model_validate()`. + +### Error handling +```python +import anthropic + +try: + response = await client.messages.create(...) +except anthropic.RateLimitError: + raise HTTPException(status_code=429, detail="Claude API rate limit exceeded") +except anthropic.APIStatusError as exc: + raise HTTPException(status_code=502, detail=f"Claude API error: {exc.status_code}") +``` +Never catch bare `Exception` or `anthropic.APIError` as the only handler. + +### Settings — API key via pydantic-settings +```python +# src/app/core/config.py (extend existing Settings) +anthropic_api_key: str = Field(default="", description="Anthropic API key") +``` +Never hardcode keys. Never read `os.environ` directly in route files. + +## Test patterns + +Mock `AsyncAnthropic` with `unittest.mock.AsyncMock`: +```python +from unittest.mock import AsyncMock, patch, MagicMock + +@pytest.fixture +def mock_anthropic(monkeypatch: pytest.MonkeyPatch) -> MagicMock: + mock = MagicMock() + mock.messages.create = AsyncMock(return_value=MagicMock( + content=[MagicMock(text="mocked response")], + usage=MagicMock(input_tokens=10, output_tokens=20), + )) + monkeypatch.setattr("app.services.my_service.client", mock) + return mock +``` + +For streaming tests use `httpx` streaming: +```python +async with client.stream("POST", "/ai/stream", json={...}) as response: + chunks = [chunk async for chunk in response.aiter_text()] +assert any("data:" in c for c in chunks) +``` + +Every new AI route requires at minimum: +- Happy path (mocked Claude response) +- Validation failure (422) — missing required fields +- Claude API error mapped to 502 + +## Forbidden + +- Sync `Anthropic` client in async handlers +- Inline prompt strings in routes +- Hardcoded API keys or model names in non-config files +- Writing outside `src/` and `tests/` +- `TestClient` — always `httpx.AsyncClient + ASGITransport` diff --git a/.claude/agents/architecture-reviewer.md b/.claude/agents/architecture-reviewer.md new file mode 100644 index 0000000..76e771b --- /dev/null +++ b/.claude/agents/architecture-reviewer.md @@ -0,0 +1,59 @@ +--- +name: architecture-reviewer +description: Read-only agent that checks BCE (Boundary-Control-Entity) layer compliance and import dependency flow in FastAPI projects. Invoke via /review. +model: claude-haiku-4-5-20251001 +tools: + - Read + - Glob + - Grep + - Bash +--- + +You are a read-only architecture reviewer. You NEVER edit or create files. You review the current branch diff for structural violations. + +## Allowed bash commands + +Only these commands are permitted: +- `git diff main...HEAD` +- `git log main...HEAD --oneline` +- `rg ` +- `python3 -c "import sys; sys.path.insert(0, 'src'); import app"` (circular import check) + +## BCE layer model for this project + +``` +routes/ ← Boundary: validate input, delegate, return response +services/ ← Control: business logic (may not exist yet — flag if missing when needed) +store/ ← Entity: data access +models/ ← Entity: domain types +``` + +Dependency flow must be strictly **downward**: +``` +routes → store → models +routes → models +store → models +``` + +Never upward. Never cross-resource (router A importing from router B's store/models). + +## Review checklist + +1. **Boundary purity** — `routes/` files must not contain business logic. Logic = conditionals that derive new values, loops over domain objects, or computations beyond "validate → call store → return". Flag route handlers longer than ~20 lines as a smell. +2. **Upward imports** — `store/` and `models/` must not import from `routes/`. Run: `rg "from app.routes" src/app/store/ src/app/models/` +3. **Cross-router imports** — no router imports from another router's store or models. Run: `rg "from app.routes" src/app/routes/` +4. **Circular imports** — run the import check command above; report any `ImportError` or `ModuleNotFoundError`. +5. **Router registration** — every new router module must be mounted in `main.py` via `app.include_router()`. Check `git diff main...HEAD -- src/app/main.py`. +6. **One resource = one router** — a single router file must not handle multiple unrelated resources. +7. **`main.py` scope** — `main.py` must only wire routers, configure lifespan, mount static files, and register exception handlers. No business logic. + +## Output format + +List each violation as: +``` +[N] : +``` + +If no violations: output exactly `LGTM — no architecture violations found.` + +Do not output summaries, explanations, or suggestions beyond the numbered list. diff --git a/.claude/agents/performance-reviewer.md b/.claude/agents/performance-reviewer.md new file mode 100644 index 0000000..bf06e0a --- /dev/null +++ b/.claude/agents/performance-reviewer.md @@ -0,0 +1,84 @@ +--- +name: performance-reviewer +description: Read-only agent that finds async anti-patterns, blocking I/O, N+1 queries, and memory leaks in FastAPI/Python code. Invoke via /review. +model: claude-haiku-4-5-20251001 +tools: + - Read + - Glob + - Grep + - Bash +--- + +You are a read-only performance reviewer. You NEVER edit or create files. You review the current branch diff for async and performance anti-patterns. + +## Allowed bash commands + +Only these commands are permitted: +- `git diff main...HEAD` +- `git log main...HEAD --oneline` +- `rg ` + +## Review checklist + +### 1. Blocking I/O in async context +Search the diff for: +- `time.sleep(` — must be `await asyncio.sleep(` +- `requests.get(`, `requests.post(`, `requests.` — use `httpx.AsyncClient` instead +- `open(` without `aiofiles` — synchronous file I/O blocks the event loop +- Synchronous DB calls in async route handlers + +Run: `rg "time\.sleep|requests\.(get|post|put|delete|patch|head)|^[^#]*\bopen\(" ` + +### 2. Missing await on coroutines +Unawaited coroutines silently return a coroutine object instead of the result. Look for: +- Assignment of async function call without `await` +- `store.create(`, `store.get(`, or any `async def` call without preceding `await` + +### 3. N+1 query patterns +A loop that calls the store once per item instead of batching: +```python +# BAD +for item_id in ids: + item = await store.get(item_id) # N queries + +# GOOD +items = await store.get_many(ids) # 1 query +``` +Flag any `for`/`async for` loop containing a store call. + +### 4. Unbounded list responses +Any endpoint returning a full collection without pagination is a risk at scale: +- `GET /` routes returning `list[Model]` with no `limit`/`offset` or `cursor` parameter +- `store.list()` calls with no upper bound + +### 5. Unclosed resources +Async clients, file handles, and streams must use `async with`: +- `httpx.AsyncClient()` not used as a context manager +- `aiofiles.open()` not used as a context manager +- Any `AsyncGenerator` not properly consumed + +### 6. Inefficient serialization +- `.model_dump()` called inside a loop — move outside +- `.model_validate()` called on already-validated objects + +### 7. PATCH over-serialization +PATCH endpoints that use `response_model=` without `response_model_exclude_unset=True` send every field including unchanged ones. Flag `router.patch(` decorators missing `response_model_exclude_unset=True`. + +### 8. Large in-memory operations +Sorting or filtering a full list in Python that should be pushed to the store layer: +```python +# BAD +items = await store.list() +return sorted(items, key=lambda x: x.created_at) # O(N) in memory +``` + +## Output format + +List each violation as: +``` +[N] : +``` + +If no violations: output exactly `LGTM — no performance issues found.` + +Do not output summaries, explanations, or suggestions beyond the numbered list. diff --git a/.claude/agents/quality-reviewer.md b/.claude/agents/quality-reviewer.md deleted file mode 100644 index 8eebf1c..0000000 --- a/.claude/agents/quality-reviewer.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: quality-reviewer -model: claude-haiku-4-5-20251001 -tools: - - Read - - Glob - - Grep - - Bash ---- - -You are the **quality-reviewer** agent. Perform a read-only code review of changes on the current branch, focusing on code quality, style, and CLAUDE.md compliance. - -## Constraints - -- **Read-only** — never edit or create files. -- Bash is restricted to: `git diff`, `git log`, `git status`, `rg`, `ruff check`, `mypy --no-error-summary`. -- Do not invoke other agents. - -## Review checklist - -For each changed file, check: - -1. **Type correctness** — every function has a return type annotation; no implicit `Any`. -2. **Test coverage** — every new route has happy path · validation failure (422) · not-found (404) tests. -3. **CLAUDE.md compliance** — cite the specific CLAUDE.md line number for each violation. -4. **FastAPI conventions** — `async def`, `response_model=`, `status_code=`, `Depends()` on every route. -5. **Test client** — `httpx.AsyncClient + ASGITransport` only; `TestClient` is a hard violation. -6. **Pydantic v2** — `.model_dump()` / `.model_validate()`; no `.dict()` or `.from_orm()`. -7. **Exception handling** — no bare `except`; `HTTPException` with meaningful `detail`. -8. **Forbidden writes** — no changes outside `src/` and `tests/` (no `.claude/`, `pyproject.toml`, `.github/`, `Dockerfile`, `.devcontainer/`). - -## Output format - -Produce a numbered list. Each item: `[N] : (CLAUDE.md L)`. - -If there are no violations, output exactly: `LGTM — no quality violations found.` - -Do not summarize or editorialize beyond the violation list. - -Read CLAUDE.md before acting. diff --git a/.claude/commands/review.md b/.claude/commands/review.md index 0eb79a1..0b596f2 100644 --- a/.claude/commands/review.md +++ b/.claude/commands/review.md @@ -1,25 +1,28 @@ -Invoke **quality-reviewer** AND **security-reviewer** in parallel on the current branch diff. +Invoke **architecture-reviewer**, **performance-reviewer**, and **security-reviewer** in parallel on the current branch diff. -Both agents must: +All three agents must: - Run `git diff main...HEAD` to see all changes - Run `git log main...HEAD --oneline` to understand the commit structure - Apply their full review checklist from their system prompt -After both complete, print their outputs under headings: +After all three complete, print their outputs under headings: ``` -## Quality Review - +## Architecture Review + + +## Performance Review + ## Security Review ``` -If either output contains violations: +If any output contains violations: - List all violations together - Suggest specific fixes with file and line references - Ask whether to invoke `/implement` to address them, or proceed to `/ship` -- If fixes are applied, re-run both reviewers (counts as iteration 1) +- If fixes are applied, re-run all three reviewers (counts as iteration 1) - Maximum 2 self-correcting iterations before escalating to the user for a manual decision -If both output LGTM, print: `All clear — run /ship to push.` +If all three output LGTM, print: `All clear — run /ship to push.` diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md index dc7f1b8..d836191 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -11,7 +11,18 @@ Run each command in sequence. If any command fails: - Print the full output of the failing command - Stop and report what needs fixing -If all four pass, open a pull request: +If all four pass, remind the user to update the version before opening the PR: + +``` +Current version in pyproject.toml: +Bump it there before merging if this release warrants a version change (patch · minor · major). +config.py reads the version from pyproject.toml automatically — no separate update needed. +Proceed to create the PR? (y/n) +``` + +Wait for confirmation before continuing. If the user says no, stop so they can bump the version first. + +If confirmed, open a pull request: ```bash gh pr create --title "" --body "$(cat <<'EOF' diff --git a/.claude/settings.json b/.claude/settings.json index 1c5e668..f1ead06 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -73,7 +73,9 @@ "WebFetch(domain:docs.astral.sh)", "WebFetch(domain:github.com)", "WebFetch(domain:api.github.com)", - "WebFetch(domain:raw.githubusercontent.com)" + "WebFetch(domain:raw.githubusercontent.com)", + "WebFetch(domain:api-inference.huggingface.co)", + "WebFetch(domain:huggingface.co)" ], "deny": [ "Read(./.env)", diff --git a/.claude/skills/blog-post/SKILL.md b/.claude/skills/blog-post/SKILL.md new file mode 100644 index 0000000..309bd32 --- /dev/null +++ b/.claude/skills/blog-post/SKILL.md @@ -0,0 +1,73 @@ +# Blog-Post Skill + +**Trigger keywords**: blog, article, write a post, technical post, blog post, write about, publish + +--- + +## Purpose + +Produce a structured technical blog post through a short audience interview followed by drafting. + +--- + +## Interview — 5 questions + +Ask these **one at a time**: + +1. **Topic** — what is the post about? One sentence. +2. **Audience** — who is the reader? (e.g. "Python developers who know FastAPI basics but haven't used async generators") +3. **Goal** — what should the reader be able to do or understand after reading? One sentence. +4. **Tone** — pick one: tutorial / deep-dive / opinion / case-study / quick-tip +5. **Call to action** — what should the reader do next? (e.g. "try the code", "read the docs", "star the repo", "nothing") + +--- + +## Post structure + +```markdown +# + +<Hook: one short paragraph. A problem, a surprising fact, or a bold claim that makes the reader want to continue.> + +## The problem +<What pain or gap does this address? Be concrete — show, don't tell.> + +## The solution +<High-level explanation. Why this approach? What makes it the right choice here?> + +## How it works +<Step-by-step or layered explanation. Include code examples from the actual project stack.> + +### Step 1 — <name> +<Explanation + code block> + +### Step 2 — <name> +<Explanation + code block> + +## Gotchas & edge cases +<2–3 things that trip people up. Honest about limitations.> + +## Takeaway +<One paragraph. What did we learn? What can the reader do right now?> + +<CTA: one sentence matching what was specified in the interview.> +``` + +--- + +## Code snippet rules + +- All code uses the project's actual stack (Python 3.11+, FastAPI, Pydantic v2, uv, httpx, pytest-asyncio) +- Snippets must be complete enough to run — no `# ... rest of code` +- Always annotate types in code examples +- If a snippet imports from the project, use the actual module paths (`from app.routes.items import ...`) + +--- + +## Rules + +- Title must be specific: "Streaming Claude responses in FastAPI with Server-Sent Events" not "Using Claude in Python" +- No filler phrases ("In this article, I will...", "As we can see...", "It's worth noting that...") +- Maximum one H1 (the title) — use H2/H3 for sections +- Keep the post to 800–1500 words unless the user specifies otherwise +- After drafting, ask: "Want me to adjust the tone, expand any section, or add more code examples?" diff --git a/.claude/skills/doc/SKILL.md b/.claude/skills/doc/SKILL.md new file mode 100644 index 0000000..c437a8a --- /dev/null +++ b/.claude/skills/doc/SKILL.md @@ -0,0 +1,96 @@ +# Doc Skill + +**Trigger paths**: `docs/**/*.md` +**Trigger keywords**: document, write docs, generate docs, README, API reference, add documentation + +--- + +## Purpose + +Read source code and configuration; write accurate project documentation into `docs/`. Never invent behaviour that isn't in the code. + +--- + +## Workflow + +1. Read `src/app/main.py` — understand routes mounted, lifespan, middleware. +2. Read `src/app/routes/*.py` — collect all endpoints (method, path, request/response models, status codes). +3. Read `src/app/models/*.py` — collect field names, types, validators. +4. Read `src/app/core/config.py` — collect all settings and their env var names. +5. Read `pyproject.toml` — collect Python version, runtime dependencies. +6. Read `Dockerfile` / `docker-compose.yml` if present — collect port, volume, env requirements. +7. Write to `docs/` only — never modify `src/`. + +--- + +## Standard doc sections + +### Project overview (`docs/README.md` or root `README.md`) +```markdown +# <Project name> + +One-paragraph description: what it does, who uses it, key technology choices. + +## Quick start +Steps to run locally (clone → configure env → start). + +## Architecture +ASCII diagram of layers (routes → store → models) and external dependencies. +``` + +### API reference (`docs/api.md`) +Auto-generate from the running app or from source: +```bash +python3 -c " +import json, sys +sys.path.insert(0, 'src') +from app.main import app +schema = app.openapi() +for path, methods in schema['paths'].items(): + for method, op in methods.items(): + print(f'{method.upper()} {path} — {op.get(\"summary\",\"\")}') +" +``` + +Format each endpoint as: +```markdown +### POST /items +**Request body**: `ItemCreate` — `name: str`, `description: str | None` +**Response** `201`: `Item` +**Response** `422`: validation error +``` + +### Environment variables (`docs/configuration.md`) +For each field in `Settings`: +```markdown +| Variable | Default | Required | Description | +|---|---|---|---| +| `APP_NAME` | `my-service` | No | Application display name | +| `ANTHROPIC_API_KEY` | — | Yes | Anthropic API key for Claude calls | +``` + +### Architecture diagram (ASCII) +``` +┌──────────────┐ HTTP ┌─────────────┐ +│ Client │ ───────────▶ │ FastAPI │ +└──────────────┘ │ routes/ │ + └──────┬──────┘ + │ Depends() + ┌──────▼──────┐ + │ store/ │ + └──────┬──────┘ + │ + ┌──────▼──────┐ + │ models/ │ + └─────────────┘ +``` + +--- + +## Rules + +- Only document behaviour that exists in the source code — never speculate. +- If a setting has no default, mark it as **Required**. +- Keep code examples runnable — test them before writing. +- Update existing docs rather than creating duplicates. +- After writing, tell the user which files were created/updated and suggest running `/review` if code was touched. diff --git a/.claude/skills/frontend/SKILL.md b/.claude/skills/frontend/SKILL.md new file mode 100644 index 0000000..9fb1ced --- /dev/null +++ b/.claude/skills/frontend/SKILL.md @@ -0,0 +1,126 @@ +# Frontend Skill + +**Trigger paths**: `src/app/ui/**`, `**/*.html`, `**/templates/**` +**Trigger keywords**: dashboard, landing page, Tailwind, HTML, frontend, UI, web page, HTMX, TailAdmin + +--- + +## FastAPI + Jinja2 — template setup + +Templates are rendered by the existing `Jinja2Templates` instance in `main.py`. Add new pages by: + +1. Creating the HTML file in `src/app/ui/` (or `src/app/templates/`) +2. Adding a GET route that calls `templates.TemplateResponse` + +```python +@router.get("/dashboard", response_class=HTMLResponse, status_code=200) +async def dashboard(request: Request) -> Response: + return templates.TemplateResponse(request, "dashboard.html", {"title": "Dashboard"}) +``` + +--- + +## Tailwind CSS — via CDN (dev / small projects) + +```html +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{{ title }} + + + + + + +``` + +For production, use the Tailwind CLI (no Node required): +```bash +# Download standalone CLI binary for the platform +# uv add --dev tailwindcss # not available — use the standalone binary +curl -sLO https://github.com/tailwindlabs/tailwindcss/releases/latest/download/tailwindcss-linux-x64 +chmod +x tailwindcss-linux-x64 +./tailwindcss-linux-x64 -i src/app/ui/input.css -o src/app/ui/output.css --minify +``` + +--- + +## Common UI patterns + +### Simple data table +```html +
+ + + + + + + + + {% for item in items %} + + + + + {% endfor %} + +
NameStatus
{{ item.name }}{{ item.status }}
+
+``` + +### HTMX — lightweight interactivity without JavaScript + +Add `htmx.org` to the template head for partial page updates: +```html + +``` + +Fetch a partial and swap into a div: +```html + +
+ {% include "partials/item_list.html" %} +
+``` + +The corresponding route returns an HTML fragment (not a full page): +```python +@router.post("/items/partial", response_class=HTMLResponse, status_code=200) +async def create_item_partial(request: Request, ...) -> Response: + return templates.TemplateResponse(request, "partials/item_card.html", {"item": new_item}) +``` + +### TailAdmin dashboard layout + +TailAdmin is a Tailwind-based admin dashboard template. Use its sidebar + topbar layout as a base: +- Sidebar: `