16 KiB
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/<feature>/. - Each blueprint:
__init__.py,routes.py, andforms.py/models.pyonly if that feature needs them. - Templates mirror blueprint structure: feature-specific templates go in
templates/<feature>/. - Every template extends
templates/base.html. - No secrets hardcoded — config comes from environment variables via
.env. - Any model change →
flask db migratethenflask 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, andgit initall completed..python-versionis set tovenv.- Known issue: inside the Claude Code sandboxed Bash tool,
$PATHdid not include~/.pyenv/shims, so the venv never actually activated in that tool —python -Vkept resolving to the system Python. - Resolution in progress: the user is activating the virtualenv manually in their own terminal (
pyenv activate venvor equivalent), then quitting and restartingclaudefrom inside that activated shell so the tool inherits the correct environment. - On resume: verify with
which python/python -Vthat the venv (Python 3.13, path containing.pyenv/versions/3.13.14/envs/venv) is active before running anypip installorflaskcommands. Once confirmed, proceed to Phase 1 (Skeleton).
Build Tutorial
Phase 0 — Environment
pyenv install 3.13 -s
pyenv local 3.13
pyenv virtualenv 3.13 venv
pyenv activate venv
git init
Phase 1 — Skeleton
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
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:
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:
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:
from app import create_app
app = create_app()
if __name__ == "__main__":
app.run()
Phase 4 — User Model
app/models.py:
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"<User {self.username}>"
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
Phase 5 — Auth Blueprint
app/auth/forms.py:
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:
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:
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:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}MyApp{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light bg-light mb-4">
<div class="container">
<a class="navbar-brand" href="{{ url_for('main.index') }}">MyApp</a>
<div class="d-flex gap-2">
{% if current_user.is_authenticated %}
<span class="navbar-text">Hi, {{ current_user.username }}</span>
<a class="btn btn-outline-secondary btn-sm" href="{{ url_for('auth.logout') }}">Log out</a>
{% else %}
<a class="btn btn-outline-primary btn-sm" href="{{ url_for('auth.login') }}">Log in</a>
<a class="btn btn-primary btn-sm" href="{{ url_for('auth.register') }}">Register</a>
{% endif %}
</div>
</div>
</nav>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
</body>
</html>
templates/index.html:
{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
<h1>Welcome{% if current_user.is_authenticated %}, {{ current_user.username }}{% endif %}!</h1>
{% if not current_user.is_authenticated %}
<p>Please <a href="{{ url_for('auth.login') }}">log in</a> or
<a href="{{ url_for('auth.register') }}">create an account</a>.</p>
{% endif %}
{% endblock %}
templates/auth/login.html:
{% extends "base.html" %}
{% block title %}Log In{% endblock %}
{% block content %}
<h2>Log In</h2>
<form method="POST" novalidate class="col-md-6">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% for error in form.email.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% for error in form.password.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
</div>
<div class="form-check mb-3">
{{ form.remember_me(class="form-check-input") }}
{{ form.remember_me.label(class="form-check-label") }}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
{% endblock %}
templates/auth/register.html:
{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<h2>Create Account</h2>
<form method="POST" novalidate class="col-md-6">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.username.label(class="form-label") }}
{{ form.username(class="form-control") }}
{% for error in form.username.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.email.label(class="form-label") }}
{{ form.email(class="form-control") }}
{% for error in form.email.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% for error in form.password.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
</div>
<div class="mb-3">
{{ form.confirm_password.label(class="form-label") }}
{{ form.confirm_password(class="form-control") }}
{% for error in form.confirm_password.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
</div>
{{ form.submit(class="btn btn-primary") }}
</form>
{% endblock %}
Phase 8 — Wire Up & Run
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:
mkdir -p app/dashboard templates/dashboard
touch app/dashboard/__init__.py app/dashboard/routes.py templates/dashboard/index.html
app/dashboard/routes.py:
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:
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
- Place
CLAUDE.mdat the project root before runningclaude. - Work phase by phase, e.g.:
claude "Implement Phase 3 exactly as specified in CLAUDE.md" - Review the diff Claude Code produces, run/test it,
git commit. - 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 upgraderuns clean on a fresh clone
Common Pitfalls
- Forgetting to set
SECRET_KEY/FLASK_APPin.env. - Circular imports between
app/__init__.pyand blueprints — always import blueprints insidecreate_app(), not at module top level. - Forgetting
login_manager.login_view = "auth.login"→@login_requiredredirects to a 404 instead of the login page. - Running the app before
flask db upgrade→ "no such table: user".