skrift
Help working with Skrift CMS codebases - a Python async CMS built on Litestar with WordPress-style conventions. Use for creating controllers, models, hooks, pages, and templates.
Files1
View on GitHubSKILL.md.claude/skills/skrift/SKILL.md
GitHubSkrift CMS Development Guide
Skrift is a lightweight async Python CMS built on Litestar, featuring WordPress-style template resolution, a hook/filter extensibility system, and SQLAlchemy async database access.
Current Project State
Configuration:
!cat app.yaml 2>/dev/null || echo "No app.yaml found"
Controllers:
!ls skrift/controllers/*.py 2>/dev/null | head -10
Models:
!ls skrift/db/models/*.py 2>/dev/null | head -10
Services:
!ls skrift/db/services/*.py 2>/dev/null | head -10
Templates:
!ls templates/*.html 2>/dev/null | head -10 || echo "No custom templates"
Quick Reference
Core Architecture
- Framework: Litestar (async Python web framework)
- Database: SQLAlchemy async with Advanced Alchemy
- Templates: Jinja2 with WordPress-style template hierarchy + optional themes (see
/skrift-theming) - Config: YAML (app.yaml) + environment variables (.env)
- Auth: OAuth providers + role-based permissions (see
/skrift-auth) - Forms: Pydantic-backed with CSRF (see
/skrift-forms) - Hooks: WordPress-style extensibility (see
/skrift-hooks) - Notifications: Real-time SSE with pluggable backends (see
/skrift-notifications) - Observability: Optional Logfire tracing and structured logging (see
/skrift-observability) - OAuth2 Server: Hub/spoke identity federation (see
/skrift-oauth2)
AppDispatcher Pattern
┌─────────────────────────────┐
│ AppDispatcher │
│ (skrift/asgi.py) │
└─────────────┬───────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────┐ ┌────────────────┐
│ Setup App │ │ /static │ │ Main App │
│ (/setup/*) │ │ Files │ │ (everything) │
└────────────────┘ └────────────┘ └────────────────┘
setup_locked=False: /setup/* routes active, checks DB for setup completionsetup_locked=True: All traffic goes to main app, /setup/* returns 404- Main app is lazily created after setup completes (no restart needed)
- Entry point:
skrift.asgi:app(created bycreate_dispatcher())
Key Files
| File | Purpose |
|---|---|
skrift/asgi.py |
AppDispatcher, app creation, middleware loading |
skrift/config.py |
Settings management, YAML config loading |
skrift/cli.py |
CLI commands (serve, secret, db) |
skrift/middleware/ |
Security headers middleware |
skrift/lib/hooks.py |
WordPress-like hook/filter system |
skrift/lib/template.py |
Template resolution with fallbacks and theme support |
skrift/lib/theme.py |
Theme discovery, metadata parsing |
skrift/db/base.py |
SQLAlchemy Base class (UUIDAuditBase) |
skrift/forms/ |
Form system (CSRF, validation, rendering) |
skrift/auth/ |
Guards, roles, permissions |
skrift/lib/notifications.py |
Real-time notification service (SSE) |
skrift/lib/notification_backends.py |
Pluggable backends (InMemory, Redis, PgNotify) |
skrift/lib/observability.py |
Logfire observability facade (tracing, logging) |
skrift/db/models/notification.py |
StoredNotification model for DB-backed backends |
Configuration System
.env (loaded early) → app.yaml (with $VAR interpolation) → Settings (Pydantic)
Environment-specific: app.yaml (production), app.dev.yaml (development), app.test.yaml (testing). Set via SKRIFT_ENV.
db:
url: $DATABASE_URL
pool_size: 5
echo: false
schema: myschema # optional; PostgreSQL only — prefixes all tables
auth:
redirect_base_url: "https://example.com"
providers:
google:
client_id: $GOOGLE_CLIENT_ID
client_secret: $GOOGLE_CLIENT_SECRET
scopes: ["openid", "email", "profile"]
session:
cookie_domain: null
theme: my-theme # default theme (overridden by admin UI)
controllers:
- skrift.controllers.auth:AuthController
- skrift.controllers.web:WebController
redis:
url: $REDIS_URL
prefix: "myapp"
notifications:
backend: "" # empty = InMemoryBackend; or "module:ClassName"
logfire:
enabled: true
service_name: my-site
console: true # prints spans to console
security_headers:
content_security_policy: "default-src 'self'"
middleware:
- myapp.middleware:create_logging_middleware
CLI Commands
skrift serve --reload --port 8080
skrift serve --subdomain blog --port 8081 # serve single subdomain site
skrift secret --write .env
skrift db upgrade head
skrift db downgrade -1
skrift db revision -m "desc" --autogenerate
Database Layer
All models inherit from skrift.db.base.Base (provides id UUID, created_at, updated_at):
from sqlalchemy import String, Text
from sqlalchemy.orm import Mapped, mapped_column
from skrift.db.base import Base
class MyModel(Base):
__tablename__ = "my_models"
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
Core Models
| Model | Table | Purpose |
|---|---|---|
User |
users |
User accounts |
OAuthAccount |
oauth_accounts |
Linked OAuth providers (access_token, refresh_token) |
Role |
roles |
Permission roles |
Page |
pages |
Content pages |
PageRevision |
page_revisions |
Content history |
Setting |
settings |
Key-value site settings |
StoredNotification |
stored_notifications |
Persistent notifications with mode column (Redis/PgNotify backends) |
Sessions injected via db_session: AsyncSession parameter in handlers.
Content Negotiation
Page views support Accept: text/markdown — returns raw page.content instead of rendered HTML. Works for all page types (WebController and page type factory routes).
Creating a Controller
from litestar import Controller, get, post
from litestar.response import Template as TemplateResponse
from sqlalchemy.ext.asyncio import AsyncSession
class MyController(Controller):
path = "/my-path"
@get("/")
async def list_items(self, db_session: AsyncSession) -> TemplateResponse:
items = await item_service.list_items(db_session)
return TemplateResponse("items/list.html", context={"items": items})
Register in app.yaml:
controllers:
- myapp.controllers:MyController
Creating a Service
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
async def get_by_id(db_session: AsyncSession, item_id: UUID) -> MyModel | None:
result = await db_session.execute(select(MyModel).where(MyModel.id == item_id))
return result.scalar_one_or_none()
async def create_item(db_session: AsyncSession, name: str) -> MyModel:
item = MyModel(name=name)
db_session.add(item)
await db_session.commit()
await db_session.refresh(item)
return item
Template Resolution
WordPress-style hierarchy with fallbacks and optional theme support:
from skrift.template import Template
# Tries: page-about.html -> page.html
template = Template("page", "about")
# Tries: post-news-2024.html -> post-news.html -> post.html
template = Template("post", "news", "2024")
# Without theme
return template.render(TEMPLATE_DIR, page=page)
# With theme (searches theme dirs first)
return template.render(TEMPLATE_DIR, theme_name="my-theme", page=page)
Search order (with active theme):
themes/<active>/templates/(active theme)./templates/(project root — user overrides)skrift/templates/(package — defaults)
Template globals: now(), site_name(), site_tagline(), site_copyright_holder(), site_copyright_start_year(), active_theme(), themes_available(). Filter: markdown.
For full theming details, see /skrift-theming.
Middleware
from litestar.middleware import AbstractMiddleware
class LoggingMiddleware(AbstractMiddleware):
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
print(f"Request: {scope['method']} {scope['path']}")
await self.app(scope, receive, send)
def create_logging_middleware(app):
return LoggingMiddleware(app=app)
Register in app.yaml:
middleware:
- myapp.middleware:create_logging_middleware
- factory: myapp.middleware:create_rate_limit
kwargs:
requests_per_minute: 100
Security
skrift/middleware/security.py — ASGI middleware injecting CSP, HSTS, X-Frame-Options, etc. Configured via SecurityHeadersConfig. Pre-encoded at creation time. HSTS excluded in debug mode.
Static files: /static/ with same priority as templates (project root, then package).
Error Handling
Custom exception handlers in skrift/lib/exceptions.py. Templates: error.html, error-404.html, error-500.html.
Testing
from litestar.testing import TestClient
async def test_list_items(client, db_session):
item = await item_service.create(db_session, name="Test")
response = client.get("/items")
assert response.status_code == 200
assert "Test" in response.text
Related Skills
For deep-dive guidance on specific subsystems:
/skrift-hooks— Hook/filter extensibility, custom hook points, built-in hooks/skrift-forms— Form system, CSRF, field customization, template rendering/skrift-auth— OAuth flow, guard system, roles, permissions/skrift-theming— Theme discovery, template/static resolution, per-request switching, RESOLVE_THEME hook/skrift-notifications— SSE protocol, pluggable backends, group keys, dismiss patterns/skrift-observability— Logfire integration, structured tracing, instrumentation/skrift-oauth2— OAuth2 Authorization Server, hub/spoke identity federation, Skrift provider