Pre-commit Template
  • Changelog

Skills

A skill is a package of structured files that teaches an AI coding agent how to work with a specific tool or framework. Install it in your agent and it will be able to run commands, edit configuration, write content, and troubleshoot problems without step-by-step guidance from you.

Any agent — install with npx:

npx skills add https://ninerealmlabs.github.io/precommit-template/

Codex / OpenCode

Tell the agent:
Fetch the skill file at https://ninerealmlabs.github.io/precommit-template/skill.md and follow the instructions.

Manual — download the skill file:

curl -O https://ninerealmlabs.github.io/precommit-template/skill.md

Or browse the SKILL.md file.

SKILL LAYOUT

precommit-template/
├── SKILL.md
└── references/
    ├── generated-files.md
    ├── survey-questions.md
    └── troubleshooting.md

SKILL.md

---
name: precommit-template
description: >
  Apply and maintain the ninerealmlabs pre-commit Copier template in a repository.
  Covers generating a config with `copier copy`, answering the survey, updating with `copier update`,
  resolving `.rej` conflicts, and the tool configs the template emits (ruff, biome, prettier, mdformat,
  rumdl, yamllint, typos, shellcheck, hadolint, editorconfig, commitizen).
  Use when adding, updating, or debugging pre-commit hooks in a repo that is (or should be) managed by
  this template.
license: CC0-1.0
compatibility: Requires copier >=9 and either prek or pre-commit. The prettier hook needs prettier on PATH.
metadata:
  author: ninerealmlabs
  repository: https://github.com/ninerealmlabs/precommit-template
  tags:
    - pre-commit
    - copier
    - linting
    - formatting
---

# Pre-commit Template

A Copier template that generates a `.pre-commit-config.yaml` plus matching tool configuration files for a repository.
Hooks are declared once and read by both [prek](https://github.com/j178/prek) (the default runner) and
[pre-commit](https://pre-commit.com/).

## Quick start

```bash
# One-time tool install
uv tool install copier --with copier-templates-extensions --with jinja2-time
uv tool install prek

# Generate into an existing repo (run from the repo root)
copier copy --trust "gh:ninerealmlabs/precommit-template" "$(git rev-parse --show-toplevel)"

prek run --all-files   # fix whatever the new rules flag
prek install           # run the checks on every commit
```

`--trust` is mandatory.
`copier.yaml` loads `extensions/detect.py` as a Jinja extension, and Copier refuses to execute extension code without it.

## Skill directory structure

```text
skills/precommit-template/
├── SKILL.md                     ← This file
└── references/
    ├── survey-questions.md      ← Every question and what it generates
    ├── generated-files.md       ← What lands in the target repo
    └── troubleshooting.md       ← Error patterns and fixes
```

## When to use what

| Need                                     | Use                                                         |
| ---------------------------------------- | ----------------------------------------------------------- |
| Add hooks to a repo for the first time   | `copier copy --trust gh:ninerealmlabs/precommit-template .` |
| Pull in newer template revisions         | `copier update --trust --answers-file .copier-answers.yaml` |
| Re-render with the same answers          | `copier recopy --trust --answers-file .copier-answers.yaml` |
| Change an answer (e.g. biome → prettier) | `copier update --trust` and re-answer at the prompt         |
| Run every hook now                       | `prek run --all-files`                                      |
| Run one hook                             | `prek run --all-files <hook-id>`                            |
| Install the git hooks                    | `prek install`                                              |
| Skip a hook for one commit               | `SKIP=<hook-id> git commit ...`                             |

## Core concepts

### The answers file

`copier copy` writes `.copier-answers.yaml` recording the template version and every answer.
`copier update` reads it to replay your choices, so **never edit it by hand** — a stale or hand-tweaked answers file makes the next update produce conflicts that look like template bugs.

### Detection instead of questions

Two values are worked out from the target repo rather than asked:

| Value             | Detected from                                                                                    | Fallback |
| ----------------- | ------------------------------------------------------------------------------------------------ | -------- |
| hook runner       | installed git hook shims, a `prek.toml`, how CI invokes the runner, or which runner is on `PATH` | `prek`   |
| `web_format_tool` | an existing `.pre-commit-config.yaml`, biome/prettier config files, or `package.json` deps       | `biome`  |

The hook runner is **not** a survey question and is **not** recorded in `.copier-answers.yaml`.
Both runners read the same `.pre-commit-config.yaml`, so the detected value only decides which runner the generated comments and post-copy messages name.
It is recomputed on every run, so it follows the repo if you switch runners.

`web_format_tool` **is** a question, because biome and prettier need genuinely different config files.
Detection only pre-selects the prompt's default; the answer is recorded and reused on every update.

### Conditional file generation

Every optional file is named with a Jinja conditional, so answering `no` means the file is never written:

```text
template/{% if python %}.ruff.toml{% endif %}.jinja
template/{% if web_format and web_format_tool == "prettier" %}.prettierrc.yaml{% endif %}.jinja
```

Full list in [references/generated-files.md](references/generated-files.md).

### Externally-installed tools

The hook runner manages its own environments for most hooks, but one tool must already be on `PATH`:

| Tool       | Required when               | Install                   |
| ---------- | --------------------------- | ------------------------- |
| `prettier` | `web_format_tool: prettier` | `npm install -g prettier` |

Without them the corresponding hooks fail with "command not found", not with a config error.

## Workflows

### Adopting the template in an existing repo

1. Commit or stash everything — Copier refuses to run against a dirty working tree.
2. Run `copier copy --trust "gh:ninerealmlabs/precommit-template" "$(git rev-parse --show-toplevel)"`.
3. Answer the survey (see [references/survey-questions.md](references/survey-questions.md)).
4. Review the generated files and the auto-created commit.
5. Run `prek run --all-files`.
   Expect a large diff on first run: the formatters rewrite the whole repo.
6. Commit the formatting churn separately from the config so review stays readable.
7. Run `prek install`.

### Updating to a newer template revision

1. Check out a feature branch — updates can touch many files.
2. Commit or stash current work.
3. Run `copier update --trust --answers-file .copier-answers.yaml`.
4. Resolve any `*.rej` files, then delete them.
   A `forbid-rej` hook blocks commits while they exist, which is deliberate: an unresolved `.rej` means template changes were silently dropped.
5. Run `prek run --all-files` and commit.

### Changing an answer

Re-run `copier update --trust` and give a different answer at the prompt.
Files gated on the old answer are deleted and files gated on the new one are created.
Local edits to a file that gets deleted are lost, so move anything worth keeping out of the way first.

## Reference files

- [references/survey-questions.md](references/survey-questions.md) — every question, its default, and the
  files and hooks it controls.
- [references/generated-files.md](references/generated-files.md) — the full map from answer to generated
  file, and which hooks read each config.
- [references/troubleshooting.md](references/troubleshooting.md) — error patterns and fixes for failed
  copies, updates, and hook runs.

## Gotchas

1. **`--trust` is not optional.**
   Without it Copier skips `extensions/detect.py` and the survey aborts on an undefined `detect_hook_runner`.
2. **Never hand-edit `.copier-answers.yaml`.**
   It is Copier's record of what was rendered; editing it desynchronizes the next update.
3. **Clean working tree required.**
   Both `copier copy` and `copier update` refuse to run with uncommitted changes.
4. **`.rej` files block commits.**
   That is the `forbid-rej` hook doing its job — resolve the rejected hunk rather than deleting the file unread.
5. **YAML files must use `.yaml`.**
   A `forbid-yml` hook fails on any `.yml` extension.
   The only carve-outs are `.copier-answers.yml` and the generator's own `great-docs.yml`.
6. **Commit messages must not carry a `Co-authored-by:` trailer.**
   Use an `AI-assistant:` trailer instead; a `commit-msg` hook enforces this.
7. **First run reformats everything.**
   Land the formatting churn in its own commit so the config change stays reviewable.
8. **`.typos.toml` may need to be committed before the `typos` hook can read it.**
   If the hook errors on a fresh copy, commit the config first, then re-run.

## Capabilities and boundaries

**What agents can do:**

- Run `copier copy`, `copier update`, and `copier recopy` in a repo
- Answer or re-answer the survey
- Resolve `.rej` conflicts
- Tune the generated tool configs (`.ruff.toml`, `.rumdl.toml`, `.typos.toml`, and friends)
- Add repo-local hooks to the generated `.pre-commit-config.yaml`

**Requires human setup:**

- Installing `copier`, a hook runner, and `prettier` if selected as the web formatter
- Deciding whether to accept a breaking template revision
- Reviewing the first bulk-formatting commit

## Resources

- [Documentation](https://ninerealmlabs.github.io/precommit-template/)
- [llms.txt](https://ninerealmlabs.github.io/precommit-template/llms.txt) — indexed overview for agents
- [llms-full.txt](https://ninerealmlabs.github.io/precommit-template/llms-full.txt) — full context for agents
- [GitHub repository](https://github.com/ninerealmlabs/precommit-template)
- [Copier documentation](https://copier.readthedocs.io/)
- [prek](https://github.com/j178/prek) · [pre-commit](https://pre-commit.com/)

references/generated-files.md

# Generated files — precommit-template

What the template writes into a target repository, and which hook reads each file.

## Contents

- Always generated
- Conditionally generated
- Naming convention in `template/`

## Always generated

| File                      | Purpose                                                                |
| ------------------------- | ---------------------------------------------------------------------- |
| `.pre-commit-config.yaml` | The hook declarations, read by both `prek` and `pre-commit`            |
| `.copier-answers.yaml`    | Copier's record of the template version and every answer — do not edit |
| `.gitattributes`          | Line-ending and diff settings                                          |

### Baseline hooks

These appear no matter how the survey is answered:

- `forbid-yml` — fails on any `.yml` extension, excluding `.copier-answers.yml`
- `forbid-rej` — fails while `*.rej` files from a conflicted update remain
- `pre-commit/pre-commit-hooks` — large files, merge conflicts, private keys, case conflicts, Python
  AST, JSON/TOML/YAML syntax, end-of-file, mixed line endings, trailing whitespace
- `remove-crlf`
- `fix-smartquotes`, `fix-ligatures`
- `gitleaks`
- `strip-exif`

The config also sets `default_install_hook_types` to `pre-commit`, `post-checkout`, `post-merge`, and `post-rewrite`, plus `pre-push` and `commit-msg` when `conventional_commits` is enabled.

`exclude` skips `*copier-answers.ya?ml` and `*.rej` globally.

## Conditionally generated

| Answer                    | File                                                                   | Read by                          |
| ------------------------- | ---------------------------------------------------------------------- | -------------------------------- |
| `ai`                      | `AGENTS.md`                                                            | AI coding agents                 |
| `editorconfig`            | `.editorconfig`                                                        | `editorconfig-checker`, editors  |
| `markdown`                | `.mdformat.toml`                                                       | `mdformat`                       |
| `markdown`                | `.rumdl.toml`                                                          | `rumdl-fmt`                      |
| `markdown_render_check`   | `<markdown_render_check_dir>/check_markdown_render.py`                 | you, on demand[^2]               |
| `github_actions`          | — (hooks only)                                                         | `check-jsonschema`, `zizmor`[^3] |
| `python`                  | `.ruff.toml`                                                           | `ruff-check`, `ruff-format`[^1]  |
| `python`                  | `tests/test_pypi_security_audit.py`, `tests/test_uv_security_audit.py` | your test runner                 |
| `docker`                  | `.hadolint.yaml`                                                       | `hadolint`                       |
| `shell`                   | `.shellcheckrc`                                                        | `shellcheck`                     |
| `web_format` + `biome`    | `.biome.jsonc`                                                         | `biome-check`                    |
| `web_format` + `prettier` | `.prettierrc.yaml`                                                     | `prettier`                       |
| `web_format` + `prettier` | `.prettierignore`                                                      | `prettier`                       |
| `yaml`                    | `.yamllint.yaml`                                                       | `yamllint`                       |
| `typos`                   | `.typos.toml`                                                          | `typos`                          |

### System-installed hooks

One hook uses `language: system`, meaning the runner will not install the tool for you:

| Hook       | Requires             | Install                   |
| ---------- | -------------------- | ------------------------- |
| `prettier` | `prettier` on `PATH` | `npm install -g prettier` |

`shellcheck` and `hadolint` need no system install: they use [shellcheck-py](https://github.com/shellcheck-py/shellcheck-py) and [hadolint-py](https://github.com/AleksaC/hadolint-py), which fetch the tool's binary when the hook environment is installed.
`shellcheck` skips `*.zsh` files, since ShellCheck has no zsh dialect.

## Naming convention in `template/`

Optional files are gated by a Jinja conditional embedded in the filename.
When the condition is false the filename renders empty and Copier writes nothing.

```text
template/{% if python %}.ruff.toml{% endif %}.jinja
template/{% if web_format and web_format_tool == "prettier" %}.prettierrc.yaml{% endif %}.jinja
template/{% if python %}tests{% endif %}/test_uv_security_audit.py
template/{% if markdown and markdown_render_check %}{{ markdown_render_check_dir }}{% endif %}/check_markdown_render.py
```

The `.jinja` suffix is stripped on render and is configured by `_templates_suffix` in `copier.yaml`.
Files without the suffix — such as the generated test files — are copied verbatim.

A directory name can also carry an answer, which is how the render check script gets a configurable destination.
The answer is substituted verbatim: a `/` anywhere in the path template splits into another directory level, so the value cannot be normalized during rendering and `markdown_render_check_dir` is validated in `copier.yaml` instead.

[^2]: Not wired to a hook. Run it after a formatting pass — `./<dir>/check_markdown_render.py` compares the
    working tree against `HEAD`, and `--run-hooks` compares against a throwaway copy the repo's own hooks
    have been run over. It needs `pandoc` on `PATH`; `--doctor` reports what is missing.

[^3]: `check-github-workflows` and `check-github-actions` validate workflow and composite-action files
    against the SchemaStore schemas; `zizmor` audits their security posture. All three are scoped by their
    own `files` patterns to `.github/workflows/`, `.github/actions/`, `dependabot.yaml`, and a top-level
    `action.yaml`, so they are inert in a repo with none of those.

[^1]: When `markdown` is also enabled, `ruff-format` gains `types_or: [python, pyi, jupyter, markdown]` and
    formats python code blocks inside markdown. `ruff-check` deliberately stays on python files only, so
    illustrative snippets in docs are not held to import and unused-name rules.

references/survey-questions.md

# Survey questions — precommit-template

Every question is defined in `copier.yaml`.
Answers are recorded in `.copier-answers.yaml` and replayed on `copier update`.

## Contents

- Asked questions
- Detected values
- Changing an answer later

## Asked questions

Every question except `markdown_render_check` defaults to `true` (or to a detected value), so accepting every default produces the full configuration without the optional render check.

| Question                    | Type | Default   | Prompt                                                                                        |
| --------------------------- | ---- | --------- | --------------------------------------------------------------------------------------------- |
| `ai`                        | bool | `true`    | Prepare AGENTS.md?                                                                            |
| `conventional_commits`      | bool | `true`    | Use conventional commits?                                                                     |
| `editorconfig`              | bool | `true`    | Use editorconfig?                                                                             |
| `markdown`                  | bool | `true`    | Lint and format markdown?                                                                     |
| `markdown_render_check`     | bool | `false`   | Include a script that checks markdown still renders the same after formatting? (needs pandoc) |
| `markdown_render_check_dir` | str  | `scripts` | Which directory should the render check script go in?                                         |
| `python`                    | bool | `true`    | Lint and format python?                                                                       |
| `docker`                    | bool | `true`    | Lint and check docker files?                                                                  |
| `github_actions`            | bool | `true`    | Validate GitHub Actions workflows and audit their security posture?                           |
| `shell`                     | bool | `true`    | Lint and format shell scripts?                                                                |
| `web_format`                | bool | `true`    | Lint and format JS/TS/JSON/HTML/CSS and related files?                                        |
| `web_format_tool`           | str  | detected  | Select the web formatter (`biome` or `prettier`)                                              |
| `yaml`                      | bool | `true`    | Lint and format YAML?                                                                         |
| `typos`                     | bool | `true`    | Check for typos?                                                                              |

`web_format_tool` is only asked when `web_format` is `true`.
`markdown_render_check` is only asked when `markdown` is `true`, and `markdown_render_check_dir` only when both are.

### What each answer controls

| Answer                    | Config files written                           | Hooks added                                                |
| ------------------------- | ---------------------------------------------- | ---------------------------------------------------------- |
| `ai`                      | `AGENTS.md`                                    | —                                                          |
| `conventional_commits`    | —                                              | `commitizen`; adds `pre-push` and `commit-msg` hook types  |
| `editorconfig`            | `.editorconfig`                                | `editorconfig-checker`                                     |
| `markdown`                | `.mdformat.toml`, `.rumdl.toml`                | `mdformat`, `rumdl-fmt`                                    |
| `markdown_render_check`   | `<dir>/check_markdown_render.py`               | —                                                          |
| `python`                  | `.ruff.toml`, `tests/test_*_security_audit.py` | `ruff-check`, `ruff-format`, `nbstripout`                  |
| `docker`                  | `.hadolint.yaml`                               | `hadolint`                                                 |
| `github_actions`          | —                                              | `check-github-workflows`, `check-github-actions`, `zizmor` |
| `shell`                   | `.shellcheckrc`                                | `shellcheck`, `shfmt`                                      |
| `web_format` + `biome`    | `.biome.jsonc`                                 | `biome-check`                                              |
| `web_format` + `prettier` | `.prettierrc.yaml`, `.prettierignore`          | `prettier` (system)                                        |
| `yaml`                    | `.yamllint.yaml`                               | `yamllint`                                                 |
| `typos`                   | `.typos.toml`                                  | `typos`                                                    |

Hooks that are always present regardless of answers: `forbid-yml`, `forbid-rej`, the `pre-commit/pre-commit-hooks` set (large files, merge conflicts, private keys, case conflicts, AST, JSON/TOML/YAML syntax, EOF, line endings, trailing whitespace), `remove-crlf`, `fix-smartquotes`, `fix-ligatures`, `gitleaks`, and `strip-exif`.

## Detected values

Two values come from inspecting the target repo rather than from a prompt.
Detection lives in `extensions/detect.py`, loaded as a Jinja extension — which is why `--trust` is required.

### `hook_runner`

Declared with `when: false`, so it is a computed value, never a question, and never written to `.copier-answers.yaml`.
It is recomputed on every run and follows the repo if the runner changes.

Detection order:

1. Installed git hook shims in `.git/hooks/`
2. A `prek.toml` in the repo
3. How CI pipelines invoke the runner
4. Which runner is on `PATH`
5. Fallback: `prek`

The value only decides which runner the generated comments and the post-copy message name.
Both runners read the same `.pre-commit-config.yaml`, so a wrong guess is cosmetic.

### `web_format_tool`

A real question whose _default_ is detected, because biome and prettier need different config files.
The answer is recorded and reused on every update.

Detection order:

1. An existing `.pre-commit-config.yaml` that already runs one of them
2. Biome or prettier config files in the repo
3. `package.json` dependencies
4. Fallback: `biome`

## Changing an answer later

Run `copier update --trust --answers-file .copier-answers.yaml` and give a different answer at the prompt.

Copier deletes files gated on the old answer and creates files gated on the new one.
Switching `web_format_tool` from `biome` to `prettier` removes `.biome.jsonc` and adds `.prettierrc.yaml` and `.prettierignore`; any local edits to the removed file are lost, so copy them somewhere safe first.

Answering `no` to a question that was previously `yes` removes that tool's config file and hook block.

references/troubleshooting.md

# Troubleshooting — precommit-template

## Contents

- Copier errors
- Update conflicts
- Hook failures
- Commit rejections

## Copier errors

### `'detect_hook_runner' is undefined`

**Cause**: `--trust` was omitted, so Copier skipped `extensions/detect.py` and the Jinja globals it registers were never defined.

**Fix**: re-run with `--trust`.
It is required on `copy`, `update`, and `recopy`.

### `Destination repository is dirty`

**Cause**: Copier refuses to run against a working tree with uncommitted changes, so it can compute a clean diff.

**Fix**: commit or stash first.

### Survey aborts partway through

**Cause**: a detection helper raised.
`detect_hook_runner()` and `detect_web_format_tool()` are called while rendering question defaults, and an exception there kills the whole survey.

**Fix**: this is a template bug, not a usage error — report it.
As a workaround, pass the value on the command line: `copier copy --trust --data web_format_tool=biome ...`.

### Nothing was generated for a tool you wanted

**Cause**: the corresponding question was answered `no`, so the conditional filename rendered empty.

**Fix**: `copier update --trust` and answer `yes`.

## Update conflicts

### `*.rej` files after `copier update`

**Cause**: Copier could not merge a template change into a file you had edited locally.

**Fix**: open each `.rej`, apply the rejected hunk by hand, then delete the `.rej` file.
The `forbid-rej` hook blocks commits until they are gone, deliberately: deleting a `.rej` unread silently drops a template change.

### Local edits vanished after switching an answer

**Cause**: changing an answer deletes files gated on the old value.
Copier renders a rename as delete-then-create, which discards local edits with no conflict marker.

**Fix**: recover from git history.
To avoid it, copy customizations out before re-answering.

### Update pulled in far more than expected

**Cause**: `copier update` renders against the _latest_ template release, not the next one, so several revisions can land at once.

**Fix**: pin with `--vcs-ref <tag>` to step forward one release at a time.

## Hook failures

### `prettier: command not found`

**Cause**: the `prettier` hook uses `language: system` — the runner does not install it.

**Fix**: install the tool (`npm install -g prettier`), or select `biome` as the web formatter instead.

### `typos` cannot find its config

**Cause**: the `typos` hook may need `.typos.toml` to already be committed before it will read it.

**Fix**: commit `.typos.toml` first, then re-run.
If that is not possible, comment out the `typos` block until the config is committed.

### `shfmt` fails to build

**Cause**: shfmt-py's sdist only builds on CPython ≤ 3.13, which is why the hook pins `language_version: python3.13`.

**Fix**: make a 3.13 interpreter available to the runner.

### The whole repo was rewritten on the first run

**Cause**: expected.
`mdformat`, `rumdl-fmt`, `ruff-format`, `shfmt`, and biome or prettier all reformat on first contact.

**Fix**: commit the formatting churn on its own so the config change stays reviewable.

### A formatter and a linter disagree

**Cause**: `mdformat` and `rumdl` both touch markdown and can fight over the same construct.

**Fix**: disable the offending rule in `.rumdl.toml`.
`MD060` (table spacing) is already disabled in this template for exactly that reason.

### Python code blocks in markdown get reformatted

**Cause**: expected.
`ruff-format` owns python code blocks inside markdown, so a single pinned ruff formats both `.py` files and the snippets in your docs.

**Fix**: none needed.
To exempt one block, wrap it in `<!-- fmt:off -->` / `<!-- fmt:on -->`.
To exempt markdown entirely, add `"*.md"` to `extend-exclude` in `.ruff.toml` and drop `markdown` from the `ruff-format` hook's `types_or`.

## Commit rejections

### `YAML file extensions must be .yaml`

**Cause**: the `forbid-yml` hook rejects any `.yml` file.

**Fix**: rename it to `.yaml`.
If a tool hard-codes `.yml` and cannot be changed, add it to the hook's `exclude` pattern in `.pre-commit-config.yaml`.

### `Commit messages must not include a Co-authored-by trailer`

**Cause**: a repo-local `commit-msg` hook requires an `AI-assistant:` trailer instead.

**Fix**: replace the trailer.

### commitizen rejects the message

**Cause**: `conventional_commits` is enabled, so messages must match the Conventional Commits format — `type(scope): subject`.

**Fix**: reword, e.g. `fix(hooks): correct shfmt args`.

Developed by ninerealmlabs.
Site created with Great Docs.