# CLAUDE.md This file is project memory for Claude Code. Drop it in the repo root, `cd` into the project, run `claude`, and Claude Code will read it automatically. Work through the phases below **in order**, one at a time — commit after each phase. ## Project Overview A multi-feature Flask app built with the **app-factory + blueprint** pattern. Every feature (auth, main, and anything you add later) lives in its own blueprint folder, so new features drop in without touching existing code. Ships with a full authentication flow (register / login / logout) and a public index page. ## Tech Stack - Python 3.11+ (pyenv-managed) - Flask 3.x — app factory pattern - Flask-SQLAlchemy — ORM, SQLite by default - Flask-Login — session-based auth - Flask-WTF + WTForms — forms, CSRF protection - Flask-Migrate — Alembic migrations - python-dotenv — env config - Jinja2 templates, Bootstrap 5 via CDN (swap for your own CI later) ## Target Project Structure ``` myapp/ ├── app/ │ ├── __init__.py # app factory │ ├── models.py # shared models (User) │ ├── auth/ │ │ ├── __init__.py │ │ ├── forms.py │ │ └── routes.py │ └── main/ │ ├── __init__.py │ └── routes.py ├── templates/ │ ├── base.html │ ├── index.html │ └── auth/ │ ├── login.html │ └── register.html ├── static/ │ └── css/ ├── config.py ├── run.py ├── requirements.txt ├── .env └── .gitignore ``` ## Conventions for Claude Code - One blueprint per feature, under `app//`. - Each blueprint: `__init__.py`, `routes.py`, and `forms.py` / `models.py` only if that feature needs them. - Templates mirror blueprint structure: feature-specific templates go in `templates//`. - Every template extends `templates/base.html`. - No secrets hardcoded — config comes from environment variables via `.env`. - Any model change → `flask db migrate` then `flask db upgrade`, never edit the DB by hand. - When asked to "implement Phase N", only touch the files listed for that phase. --- ## Current Session Status (resume point) Phase 0 is mostly done: - `pyenv install 3.13 -s`, `pyenv local 3.13`, `pyenv virtualenv 3.13 venv`, and `git init` all completed. - `.python-version` is set to `venv`. - **Known issue:** inside the Claude Code sandboxed Bash tool, `$PATH` did not include `~/.pyenv/shims`, so the venv never actually activated in that tool — `python -V` kept resolving to the system Python. - **Resolution in progress:** the user is activating the virtualenv manually in their own terminal (`pyenv activate venv` or equivalent), then quitting and restarting `claude` from inside that activated shell so the tool inherits the correct environment. - **On resume:** verify with `which python` / `python -V` that the venv (Python 3.13, path containing `.pyenv/versions/3.13.14/envs/venv`) is active before running any `pip install` or `flask` commands. Once confirmed, proceed to Phase 1 (Skeleton). --- ## Build Tutorial ### Phase 0 — Environment ```bash pyenv install 3.13 -s pyenv local 3.13 pyenv virtualenv 3.13 venv pyenv activate venv git init ``` ### Phase 1 — Skeleton ```bash mkdir -p app/auth app/main templates/auth static/css touch app/__init__.py app/models.py \ app/auth/__init__.py app/auth/routes.py app/auth/forms.py \ app/main/__init__.py app/main/routes.py \ templates/base.html templates/index.html \ templates/auth/login.html templates/auth/register.html \ config.py run.py requirements.txt .env .gitignore ``` ### Phase 2 — Dependencies `requirements.txt`: ``` Flask==3.0.3 Flask-SQLAlchemy==3.1.1 Flask-Login==0.6.3 Flask-WTF==1.2.1 Flask-Migrate==4.0.7 python-dotenv==1.0.1 email-validator==2.1.1 ``` ```bash pip install -r requirements.txt ``` `.env`: ``` FLASK_APP=run.py FLASK_DEBUG=1 SECRET_KEY=change-me-to-a-random-string DATABASE_URL=sqlite:///app.db ``` `.gitignore`: ``` .venv/ __pycache__/ *.pyc instance/ .env app.db ``` **Prompt for Claude Code:** ``` claude "Implement Phase 2 exactly as specified in CLAUDE.md: create requirements.txt, .env, and .gitignore with the given content." ``` ### Phase 3 — Config & App Factory `config.py`: ```python import os from dotenv import load_dotenv load_dotenv() class Config: SECRET_KEY = os.environ.get("SECRET_KEY", "dev-key-not-safe") SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///app.db") SQLALCHEMY_TRACK_MODIFICATIONS = False ``` `app/__init__.py`: ```python from flask import Flask from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager from flask_migrate import Migrate from config import Config db = SQLAlchemy() login_manager = LoginManager() migrate = Migrate() def create_app(config_class=Config): app = Flask(__name__) app.config.from_object(config_class) db.init_app(app) login_manager.init_app(app) migrate.init_app(app, db) login_manager.login_view = "auth.login" login_manager.login_message_category = "info" from app.auth.routes import auth_bp from app.main.routes import main_bp app.register_blueprint(auth_bp, url_prefix="/auth") app.register_blueprint(main_bp) from app import models # noqa: F401 — registers models with SQLAlchemy return app ``` `run.py`: ```python from app import create_app app = create_app() if __name__ == "__main__": app.run() ``` ### Phase 4 — User Model `app/models.py`: ```python from datetime import datetime from flask_login import UserMixin from werkzeug.security import generate_password_hash, check_password_hash from app import db, login_manager class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(64), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) password_hash = db.Column(db.String(255), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow) def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) def __repr__(self): return f"" @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) ``` ### Phase 5 — Auth Blueprint `app/auth/forms.py`: ```python from flask_wtf import FlaskForm from wtforms import StringField, PasswordField, BooleanField, SubmitField from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError from app.models import User class RegistrationForm(FlaskForm): username = StringField("Username", validators=[DataRequired(), Length(min=3, max=64)]) email = StringField("Email", validators=[DataRequired(), Email()]) password = PasswordField("Password", validators=[DataRequired(), Length(min=8)]) confirm_password = PasswordField( "Confirm Password", validators=[DataRequired(), EqualTo("password")] ) submit = SubmitField("Create Account") def validate_username(self, username): if User.query.filter_by(username=username.data).first(): raise ValidationError("That username is already taken.") def validate_email(self, email): if User.query.filter_by(email=email.data).first(): raise ValidationError("That email is already registered.") class LoginForm(FlaskForm): email = StringField("Email", validators=[DataRequired(), Email()]) password = PasswordField("Password", validators=[DataRequired()]) remember_me = BooleanField("Remember Me") submit = SubmitField("Log In") ``` `app/auth/routes.py`: ```python from urllib.parse import urlparse from flask import Blueprint, render_template, redirect, url_for, flash, request from flask_login import login_user, logout_user, login_required, current_user from app import db from app.models import User from app.auth.forms import RegistrationForm, LoginForm auth_bp = Blueprint("auth", __name__, template_folder="../../templates/auth") @auth_bp.route("/register", methods=["GET", "POST"]) def register(): if current_user.is_authenticated: return redirect(url_for("main.index")) form = RegistrationForm() if form.validate_on_submit(): user = User(username=form.username.data, email=form.email.data) user.set_password(form.password.data) db.session.add(user) db.session.commit() flash("Account created. You can log in now.", "success") return redirect(url_for("auth.login")) return render_template("auth/register.html", form=form) @auth_bp.route("/login", methods=["GET", "POST"]) def login(): if current_user.is_authenticated: return redirect(url_for("main.index")) form = LoginForm() if form.validate_on_submit(): user = User.query.filter_by(email=form.email.data).first() if user is None or not user.check_password(form.password.data): flash("Invalid email or password.", "danger") return redirect(url_for("auth.login")) login_user(user, remember=form.remember_me.data) next_page = request.args.get("next") if not next_page or urlparse(next_page).netloc != "": next_page = url_for("main.index") return redirect(next_page) return render_template("auth/login.html", form=form) @auth_bp.route("/logout") @login_required def logout(): logout_user() flash("You have been logged out.", "info") return redirect(url_for("main.index")) ``` ### Phase 6 — Main Blueprint `app/main/routes.py`: ```python from flask import Blueprint, render_template main_bp = Blueprint("main", __name__) @main_bp.route("/") def index(): return render_template("index.html") ``` ### Phase 7 — Templates `templates/base.html`: ```html {% block title %}MyApp{% endblock %}
{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %}
{{ message }}
{% endfor %} {% endif %} {% endwith %} {% block content %}{% endblock %}
``` `templates/index.html`: ```html {% extends "base.html" %} {% block title %}Home{% endblock %} {% block content %}

Welcome{% if current_user.is_authenticated %}, {{ current_user.username }}{% endif %}!

{% if not current_user.is_authenticated %}

Please log in or create an account.

{% endif %} {% endblock %} ``` `templates/auth/login.html`: ```html {% extends "base.html" %} {% block title %}Log In{% endblock %} {% block content %}

Log In

{{ form.hidden_tag() }}
{{ form.email.label(class="form-label") }} {{ form.email(class="form-control") }} {% for error in form.email.errors %}
{{ error }}
{% endfor %}
{{ form.password.label(class="form-label") }} {{ form.password(class="form-control") }} {% for error in form.password.errors %}
{{ error }}
{% endfor %}
{{ form.remember_me(class="form-check-input") }} {{ form.remember_me.label(class="form-check-label") }}
{{ form.submit(class="btn btn-primary") }}
{% endblock %} ``` `templates/auth/register.html`: ```html {% extends "base.html" %} {% block title %}Register{% endblock %} {% block content %}

Create Account

{{ form.hidden_tag() }}
{{ form.username.label(class="form-label") }} {{ form.username(class="form-control") }} {% for error in form.username.errors %}
{{ error }}
{% endfor %}
{{ form.email.label(class="form-label") }} {{ form.email(class="form-control") }} {% for error in form.email.errors %}
{{ error }}
{% endfor %}
{{ form.password.label(class="form-label") }} {{ form.password(class="form-control") }} {% for error in form.password.errors %}
{{ error }}
{% endfor %}
{{ form.confirm_password.label(class="form-label") }} {{ form.confirm_password(class="form-control") }} {% for error in form.confirm_password.errors %}
{{ error }}
{% endfor %}
{{ form.submit(class="btn btn-primary") }}
{% endblock %} ``` ### Phase 8 — Wire Up & Run ```bash flask db init flask db migrate -m "initial: User model" flask db upgrade flask run ``` Visit `http://127.0.0.1:5000/` → should show the index page. Visit `/auth/register` → create an account → redirected to `/auth/login`. Log in → index page now greets you by username and shows "Log out". ### Phase 9 — Adding a New Feature (this is the "multiple feature" part) Every additional feature follows the same pattern as `auth`/`main`. Example — adding a `dashboard` feature that only logged-in users can see: ```bash mkdir -p app/dashboard templates/dashboard touch app/dashboard/__init__.py app/dashboard/routes.py templates/dashboard/index.html ``` `app/dashboard/routes.py`: ```python from flask import Blueprint, render_template from flask_login import login_required dashboard_bp = Blueprint("dashboard", __name__, template_folder="../../templates/dashboard") @dashboard_bp.route("/dashboard") @login_required def index(): return render_template("dashboard/index.html") ``` Register it in `app/__init__.py` alongside the other blueprints: ```python from app.dashboard.routes import dashboard_bp app.register_blueprint(dashboard_bp) ``` Repeat this pattern for every new feature — that's the whole point of the blueprint layout: no existing file changes when a feature is added, only new ones. --- ## Using This File With the Claude Code CLI 1. Place `CLAUDE.md` at the project root before running `claude`. 2. Work phase by phase, e.g.: ``` claude "Implement Phase 3 exactly as specified in CLAUDE.md" ``` 3. Review the diff Claude Code produces, run/test it, `git commit`. 4. Move to the next phase. Don't skip ahead — later phases assume earlier files exist exactly as specified. ## Testing Checklist - [ ] Can register a new user - [ ] Duplicate email/username is rejected with a clear error - [ ] Password is hashed, never stored in plain text - [ ] Can log in and log out - [ ] Index page differs for logged-in vs. anonymous users - [ ] CSRF token present in both forms (`form.hidden_tag()`) - [ ] `flask db upgrade` runs clean on a fresh clone ## Common Pitfalls - Forgetting to set `SECRET_KEY` / `FLASK_APP` in `.env`. - Circular imports between `app/__init__.py` and blueprints — always import blueprints *inside* `create_app()`, not at module top level. - Forgetting `login_manager.login_view = "auth.login"` → `@login_required` redirects to a 404 instead of the login page. - Running the app before `flask db upgrade` → "no such table: user".