Project Setup

CLI Project Scaffolding with Cookiecutter

Generate production-ready Python CLI project scaffolds with Cookiecutter templates — enforce pyproject.toml structure, src layout, and team consistency.

Updated

Every new Python CLI starts with the same forgettable chores: a pyproject.toml, a src/ directory, an entry point wired into [project.scripts], a test folder, a license, and a CI file. Do this by hand once and you have a project; do it ten times across a team and you have ten subtly different projects. Cookiecutter turns that boilerplate into a parameterized template you generate in one command, so every CLI your team ships starts from the same correct skeleton.

TL;DR

  • Cookiecutter renders a directory tree from Jinja2 templates. Variables come from a cookiecutter.json at the template root; the rendered project lives under a {{cookiecutter.project_slug}}/ directory.
  • Generate a project with cookiecutter gh:your-org/cli-template (or a local path). Cookiecutter prompts for each variable, then renders the tree.
  • Put the real structure in the template: src/ layout, a pyproject.toml with [project.scripts], tests, and CI.
  • Use hooks/pre_gen_project.py to validate answers and hooks/post_gen_project.py to delete unused files, init git, or fail fast on bad input.
  • The win is team consistency — one reviewed template means every CLI has the same layout, the same entry-point convention, and the same lint config.
Cookiecutter renders a template into a generated project From template to generated project Template Jinja2 {{cookiecutter.project_slug}}/ pyproject.toml src/ {{cookiecutter.package_name}}/ __init__.py tests/ cookiecutter Generated project myapp/ pyproject.toml src/ myapp/ __init__.py tests/

What Cookiecutter is, and when to reach for it

Cookiecutter (pip install cookiecutter, or run it once with uvx cookiecutter) is a project generator. You point it at a template — a local directory, a Git URL, or a zip — and it asks you the questions defined in that template's cookiecutter.json, then writes out a fully rendered project with your answers substituted in.

Use it when you create new CLIs often enough that the setup tax is real, or when more than one person needs to produce projects that look the same. A single developer scaffolding one tool a year does not need it. A platform team that owns a dozen internal CLIs — each needing the same logging setup, the same --version flag, the same release workflow — gets compounding value: fix the template once and every future project inherits the fix.

The alternative most people reach for first is "copy the last project and delete the bits I don't need." That works until the copied project drifts, carries stale dependencies, or leaks a hardcoded package name into three files you forgot to rename. A template makes the rename a variable.

Template directory layout

A Cookiecutter template is itself a directory. The one rule that matters: the project being generated lives inside a directory whose name is a Jinja2 expression, conventionally {{cookiecutter.project_slug}}. Everything outside that directory (the cookiecutter.json, the hooks/) is template machinery and is not copied into the output.

How a template directory is laid out A Cookiecutter template with a variables file, a templated project directory whose name is itself a variable, and generation hooks. How a template directory is laid out my-cli-template/ the repository you share cookiecutter.json the questions and defaults {{cookiecutter.slug}}/ everything inside is rendered hooks/ pre_gen and post_gen scripts the braces in the directory name are what make the generated project name configurable Anything outside the templated directory is template infrastructure and never reaches the generated project.
cli-template/
├── cookiecutter.json
├── hooks/
│   ├── pre_gen_project.py
│   └── post_gen_project.py
└── {{cookiecutter.project_slug}}/
    ├── pyproject.toml
    ├── README.md
    ├── .pre-commit-config.yaml
    ├── src/
    │   └── {{cookiecutter.package_name}}/
    │       ├── __init__.py
    │       ├── __main__.py
    │       └── cli.py
    └── tests/
        └── test_cli.py

Notice the nested {{cookiecutter.package_name}}/ directory under src/. Cookiecutter templates both file contents and file/directory names, so the import package gets named correctly without any post-processing. This is what makes the src/ layout work cleanly: the rendered tree is src/your_package/ with a name derived from the answers, not a fixed string you have to find-and-replace.

A realistic cookiecutter.json

The cookiecutter.json defines variables and their defaults. Order matters — earlier answers can feed later defaults through Jinja2 expressions. List values become a "choose one" menu. The leading-underscore keys are reserved settings, not prompts.

{
  "project_name": "My CLI Tool",
  "project_slug": "{{ cookiecutter.project_name.lower().replace(' ', '-') }}",
  "package_name": "{{ cookiecutter.project_slug.replace('-', '_') }}",
  "command_name": "{{ cookiecutter.project_slug }}",
  "author_name": "Your Name",
  "author_email": "you@example.com",
  "python_version": "3.12",
  "cli_framework": ["typer", "click", "argparse"],
  "license": ["MIT", "Apache-2.0", "Proprietary"],
  "use_precommit": ["yes", "no"],
  "_copy_without_render": [".github/workflows/*.yml"]
}

Two derivations carry the load. project_slug turns "My CLI Tool" into my-cli-tool for the directory and the distribution name, and package_name turns that into my_cli_tool — a valid Python identifier for the import package. Getting these right in the template means the rendered project's distribution name and import name follow the standard convention (dashes in the dist name, underscores in the module) without the author thinking about it.

_copy_without_render is a safety hatch: GitHub Actions files use ${{ ... }} syntax that collides with Jinja2's {{ ... }}, so you tell Cookiecutter to copy those paths verbatim instead of trying to render them.

The generated pyproject.toml

This is the heart of the template — the file that makes the output a real, installable CLI. The Jinja2 expressions are resolved at generation time against the answers above.

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "{{ cookiecutter.project_slug }}"
version = "0.1.0"
description = "{{ cookiecutter.project_name }} — a command-line tool."
authors = [{ name = "{{ cookiecutter.author_name }}", email = "{{ cookiecutter.author_email }}" }]
requires-python = ">={{ cookiecutter.python_version }}"
readme = "README.md"
license = { text = "{{ cookiecutter.license }}" }
dependencies = [
{%- if cookiecutter.cli_framework == "typer" %}
  "typer>=0.12",
{%- elif cookiecutter.cli_framework == "click" %}
  "click>=8.1",
{%- endif %}
]

[project.scripts]
{{ cookiecutter.command_name }} = "{{ cookiecutter.package_name }}.cli:app"

[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.5"]

[tool.hatch.build.targets.wheel]
packages = ["src/{{ cookiecutter.package_name }}"]

[tool.ruff]
target-version = "py{{ cookiecutter.python_version.replace('.', '') }}"
src = ["src", "tests"]

The [project.scripts] line is what installs the mytool command onto the user's PATH. It points at package.cli:app — the framework's callable in the rendered module. The {%- if %} block adds the framework dependency that matches the chosen cli_framework, and only that one. The [tool.hatch.build.targets.wheel] section tells the build backend that the package lives under src/, which is required for the src/ layout to package correctly. For the deeper rationale on choosing app versus a function and registering it as a console script, see best practices for Python CLI entry points.

The rendered src/{{cookiecutter.package_name}}/cli.py is the matching minimal entry point:

import typer

app = typer.Typer(help="{{ cookiecutter.project_name }}")


@app.command()
def hello(name: str = "world") -> None:
    """Print a greeting."""
    typer.echo(f"Hello, {name}!")


if __name__ == "__main__":
    app()

Pre- and post-generation hooks

Hooks are ordinary Python scripts in hooks/. The pre_gen_project.py runs before rendering (in a temp context) and is the place to reject invalid answers; post_gen_project.py runs after rendering, with the current working directory set to the root of the freshly generated project. The post hook is where you delete files the chosen options don't need, initialize git, or print next-steps.

When each hook runs The generation order: answers are collected, the pre-generation hook validates them, files are rendered, and the post-generation hook finishes the project. When each hook runs Answers prompts or --no-input 1 pre_gen validate the slug 2 Render files and directory names 3 post_gen git init, remove unused files 4 a non-zero exit from either hook aborts generation and leaves no half-made project Validate in pre_gen: rejecting a bad package name before rendering is much kinder than cleaning up after it.

A pre_gen_project.py validation guard — exit non-zero and Cookiecutter aborts the whole generation, leaving nothing behind:

import re
import sys

PACKAGE_NAME = "{{ cookiecutter.package_name }}"

if not re.match(r"^[a-z][a-z0-9_]+$", PACKAGE_NAME):
    print(f"ERROR: '{PACKAGE_NAME}' is not a valid Python package name.")
    sys.exit(1)

The post hook does the cleanup and git init. This is real, runnable Python — Cookiecutter substitutes the {{ ... }} literal before executing it, so the conditional reads a plain string at runtime:

"""Post-generation hook: runs from the root of the freshly rendered project."""

from __future__ import annotations

import shutil
import subprocess
import sys
from pathlib import Path

PROJECT_ROOT = Path.cwd()

# Cookiecutter renders this literal into the conditional below at generation time.
USE_PRECOMMIT = "{{ cookiecutter.use_precommit }}" == "yes"


def remove_unused_paths() -> None:
    """Delete optional files the chosen options don't need."""
    if not USE_PRECOMMIT:
        config = PROJECT_ROOT / ".pre-commit-config.yaml"
        config.unlink(missing_ok=True)


def init_git_repo() -> None:
    """Initialize a git repo if git is available; never fail the generation."""
    if shutil.which("git") is None:
        print("git not found on PATH — skipping repo init", file=sys.stderr)
        return
    try:
        subprocess.run(["git", "init", "--quiet"], cwd=PROJECT_ROOT, check=True)
    except subprocess.CalledProcessError as exc:
        print(f"git init failed ({exc.returncode}) — continuing", file=sys.stderr)


def main() -> int:
    remove_unused_paths()
    init_git_repo()
    print(f"Scaffolded project in {PROJECT_ROOT}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

The pattern worth copying: cleanup is driven by pathlib with missing_ok=True so a second run never crashes, and anything that touches the outside world (git) degrades gracefully instead of aborting a generation that already succeeded. A post hook that raises on a missing optional file is the most common way to leave a half-generated project on disk.

Generating a project

With the template published — or sitting in a local directory — generation is one command:

# From a Git host (GitHub shortcut):
cookiecutter gh:your-org/cli-template

# From a local checkout:
cookiecutter ./cli-template

# Run without installing it globally:
uvx cookiecutter gh:your-org/cli-template

# Skip the prompts in CI, overriding only what you need:
cookiecutter gh:your-org/cli-template --no-input \
  project_name="Deploy Bot" cli_framework=click

After generation, the rendered project is ready to install in editable mode and run:

cd deploy-bot
uv venv && uv pip install -e ".[dev]"
deploy-bot hello --name team

Why this works (and the trade-offs)

The value of Cookiecutter is centralization. The structural decisions — src/ layout, where the entry point lives, which build backend, how lint is configured — get made once, in a template that a senior engineer reviews, instead of re-litigated in every new repo. When you bump the minimum Python version or switch build backends, you change the template and every project generated afterward is correct.

The trade-off is that Cookiecutter is a one-shot generator: it scaffolds a project at time zero and then walks away. Once a developer runs it, their project is theirs — there is no link back to the template, so improvements you make later do not flow into already-generated projects. Tools like Cruft and Copier exist specifically to add that update path. If "keep 40 services in sync with the template" is your actual problem, evaluate Copier instead; if "stop people hand-rolling pyproject.toml" is the problem, Cookiecutter is the right size.

The second trade-off is templating noise. A pyproject.toml full of {%- if %} blocks is harder to read and easy to break — a misplaced whitespace-control marker ({%- vs {%) silently mangles your output. Keep conditionals shallow; if a template grows three levels of nested logic, that is a sign you want two templates, not one heroic one.

Production notes

  • Test the template, not just the output. Cookiecutter has a pytest plugin, pytest-cookies, that bakes the template into a temp directory inside a test. Add a CI job that bakes the project, then runs uv pip install -e . and the generated test suite inside it. A template that generates a project that does not install is worse than no template.
  • _copy_without_render for collision-prone files. Any file that legitimately contains {{ or {% — GitHub Actions workflows, some YAML config, Jinja-templated app files — must be listed there, or Cookiecutter will try to render it and fail or corrupt it.
  • Pin the framework versions in the template. The generated pyproject.toml above uses floors like typer>=0.12. That gives new projects a known-good baseline; let each project's lockfile pin the exact versions.
  • Hooks run with the interpreter that runs Cookiecutter. They are not isolated in the project's venv. Keep them to the standard library (pathlib, subprocess, re, shutil) so they work regardless of what the generated project depends on.
  • Idempotent cleanup. Use unlink(missing_ok=True) and shutil.rmtree(..., ignore_errors=True) in post hooks so re-runs and partial states do not crash.

Keeping a template alive

The failure mode of every project template is the same: it works beautifully for three months and then generates a project that does not build. The fix is to treat the template as a real project with its own CI.

# .github/workflows/generate.yml — in the template repository
jobs:
  generate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: pipx install cookiecutter
      - run: cookiecutter --no-input . -o /tmp/out
      - working-directory: /tmp/out/my-cli
        run: |
          pipx install uv
          uv sync
          uv run pytest -q
          uv build
          uv run --isolated --with dist/*.whl my-cli --version

That workflow generates a project from the template on every push and then puts the generated project through the same steps a real one would face: install from the lockfile, run the tests, build a wheel, install it, run the command. When a dependency changes incompatibly or a hook revision is yanked, the template's own CI goes red on a Tuesday rather than on somebody's first day.

Two smaller habits help as much. Pin what the template pins — hook revisions, action versions, the minimum Python — and update them deliberately, so a generated project is reproducible rather than a snapshot of what PyPI looked like that morning. And generate with --no-input in CI, which forces the defaults in cookiecutter.json to be genuinely valid rather than placeholders somebody was expected to replace.

Cookiecutter, Copier, or neither

Three approaches, and the right answer depends on one question: will generated projects need to receive template updates later?

Cookiecutter renders once and forgets. It is simple, ubiquitous, and its templates are just directories with Jinja in the names and contents. If your projects diverge immediately after creation — which is the common case for CLIs — this is all you need.

Copier renders and remembers: it records the answers and the template version in the generated project, so copier update can replay later template changes into it. That is genuinely valuable when you maintain a fleet of similar services and want to roll a CI change across all of them. It costs a little more ceremony, and the update mechanism needs your templates to be written with merges in mind.

Neither is a reasonable answer for a small team with one or two new projects a year. A well-maintained example repository and ten minutes of copying is less machinery to own than a template that nobody exercises. The moment you find yourself copying the same four files for the third time, revisit that.

Whichever you choose, resist making the template configurable beyond what genuinely varies. Every question in cookiecutter.json is a branch in the generated output that somebody has to test, and a template with fifteen prompts is one nobody wants to run.

What belongs in the generated project

A skeleton earns its keep by including the things people forget, not by including everything.

The list worth generating: a src/ layout with the package and a working entry point; a pyproject.toml with the four essential tables and a dev dependency group; a lockfile; a test that imports the package and one that invokes the CLI through the runner; a pre-commit configuration with pinned revisions; a CI workflow that installs from the lockfile, runs the tests, builds the wheel and smoke-tests it; a README with install and usage sections; and a .gitignore that covers .venv, dist and __pycache__.

The list worth leaving out: a Dockerfile nobody asked for, documentation scaffolding for a docs site that may never exist, a CHANGELOG.md full of placeholder text, and licence headers in every file. Each of those is something the first developer has to delete, and a template that requires deletion trains people to distrust it.

One thing worth generating that people rarely do: a first commit. A post-generation hook that runs git init and commits the skeleton means the very first real change shows up as a reviewable diff against a known baseline.

Frequently asked questions

Should the template include the lockfile?

Generate it in a post-generation hook rather than shipping a stale one. A lockfile committed into the template ages badly — it pins versions from whenever the template was last touched — while a hook that runs uv lock produces a current one at generation time and fails loudly if the declared dependencies no longer resolve.

How do I test a template's hooks?

Run the generation in CI with --no-input, which exercises pre_gen_project and post_gen_project exactly as a user would. For validation logic specifically, add a second job that generates with a deliberately invalid answer and asserts the generation fails — an unvalidated package name is the classic way a template produces a project that cannot be imported.

Can a template contain files that are conditionally generated?

Yes, and the cleanest way is to generate everything and delete in post_gen_project, since Jinja cannot easily omit a file. Keep the conditions few: every optional file doubles the number of generated shapes and halves the chance any of them is tested.

What about {{ in the template's own content?

Escape it with {% raw %} blocks. This bites most often in CI workflow files, where GitHub Actions expressions use the same braces as Jinja — a template that generates a broken workflow is a frustrating way to spend an afternoon.

Is a template worth it for a solo developer?

If you start more than a couple of projects a year, yes — not for the time saved but for the consistency. The value is that every project has the same layout, the same commands and the same CI, so context-switching between them costs nothing. That benefit is entirely independent of team size.