Project Setup

Shipping Your Python CLI as a pre-commit Hook

Let other repositories run your Python CLI through pre-commit: a .pre-commit-hooks.yaml, filename handling, exit codes, auto-fixing, release tags and tests.

Updated

Your CLI checks or fixes files — it validates deployment configs, lints SQL, formats Terraform variables, enforces naming rules on Kubernetes manifests. People already run it by hand or in CI, and the natural next request is "can I run it on commit?". pre-commit is the standard answer: a user adds a few lines naming your repository and hook ID to their .pre-commit-config.yaml, and pre-commit installs your tool into an isolated environment and runs it on every commit, passing only the staged files. This guide shows how to publish a hook from your CLI's repository: the .pre-commit-hooks.yaml contract, making your command behave well with filename arguments, deciding between checking and fixing, releasing, and testing the hook before anyone else uses it. It belongs to the pre-commit hooks for CLI projects topic.

Prerequisites

  • A Python CLI in a public or shared git repository, installable with pip install . (a pyproject.toml with [project.scripts]).
  • A command that accepts file paths as arguments — most checkers already do.
  • pre-commit installed for testing: uv tool install pre-commit.

How pre-commit runs someone else's tool

How pre-commit runs your CLI A user references your repository in their pre-commit config, pre-commit clones it at the pinned tag, installs it into an isolated environment and runs your command with staged filenames. How pre-commit runs your CLI User config repo + rev Clone at rev your tag Isolated venv pip install . Run entry + staged files autoupdate cached filenames Your tool never sees the user's environment; it gets its own, built from your repository.

When a user's configuration references your repository at a tag, pre-commit clones your repository at that tag, creates a virtual environment, runs pip install . in it, and caches the result. On each commit it runs your hook's entry command inside that environment, appending the paths of staged files that match your hook's filters. If the command exits non-zero, or modifies any file, the commit is stopped.

Two consequences shape everything else. Your tool runs in its own environment, built from your repository — not the user's project environment — so it cannot import the user's code. And it receives file paths on the command line, possibly many of them, possibly split across several invocations when there are too many for one command line.

The recipe: .pre-commit-hooks.yaml

Add this file to the root of your CLI's repository:

# .pre-commit-hooks.yaml
- id: mytool-check
  name: mytool config check
  description: Validate deployment configuration files.
  entry: mytool check
  language: python
  types_or: [yaml, json]
  files: ^(deploy|config)/
  require_serial: false
  minimum_pre_commit_version: "3.0.0"

- id: mytool-fix
  name: mytool config fix
  description: Rewrite deployment configuration files into canonical form.
  entry: mytool fix
  language: python
  types_or: [yaml, json]
  files: ^(deploy|config)/
The .pre-commit-hooks.yaml contract The fields of a pre-commit hook definition shipped in your repository: id, name, entry, language, file filters and pass_filenames. The .pre-commit-hooks.yaml contract .pre-commit-hooks.yaml in your repo root id: mytool-check what users reference entry: mytool check your console script language: python isolated venv types: [yaml] which files it sees require_serial for tools that lock args: defaults users can override minimum_pre_commit_version if needed The id is a public API: renaming it breaks every repository that uses the hook.

Each field has a job:

  • id is what users reference. It is a public API: renaming it breaks every configuration that uses it.
  • entry is the command to run. With language: python it is resolved inside the hook's environment, so it can be your console script.
  • language: python tells pre-commit to build an isolated virtual environment from your repository. Users need nothing else installed.
  • types_or and files filter which staged files are passed. Filtering here, rather than inside your tool, means your tool is not even started for commits that do not touch relevant files.
  • require_serial controls parallelism. By default pre-commit may split the file list and run several copies of your command concurrently; set it to true if your tool keeps a cache or lock that concurrent runs would fight over.

Users then enable it with:

# their .pre-commit-config.yaml
repos:
  - repo: https://github.com/acme/mytool
    rev: v2.4.0
    hooks:
      - id: mytool-check

The recipe: a command that behaves like a good hook

pre-commit's contract is simple: files arrive as arguments, the exit code says pass or fail, and any modification to a file counts as a failure (so the user reviews and re-stages the change). Make your command fit it:

# src/mytool/cli.py
from pathlib import Path
from typing import Annotated

import typer

from mytool.validate import Problem, canonical, validate_file

app = typer.Typer()


@app.callback()
def main() -> None:
    """Deployment configuration tools."""


@app.command()
def check(files: Annotated[list[Path], typer.Argument(exists=True, dir_okay=False)]) -> None:
    """Validate FILES; exit 1 if any problem is found."""
    problems: list[Problem] = []
    for path in files:
        problems.extend(validate_file(path))
    for p in problems:
        typer.echo(f"{p.path}:{p.line}: {p.message}")        # file:line: message
    raise typer.Exit(1 if problems else 0)


@app.command()
def fix(files: Annotated[list[Path], typer.Argument(exists=True, dir_okay=False)]) -> None:
    """Rewrite FILES into canonical form; exit 1 if anything changed."""
    changed = 0
    for path in files:
        before = path.read_text(encoding="utf-8")
        after = canonical(before)
        if after != before:
            path.write_text(after, encoding="utf-8")
            typer.echo(f"fixed {path}")
            changed += 1
    raise typer.Exit(1 if changed else 0)

The details that make a hook pleasant:

  • Accept many files in one call and handle each independently; report problems for all of them before exiting.
  • Use file:line: message output. Editors, CI log viewers and humans all recognise it, and many terminals make it clickable.
  • Exit 1 when a fixer changes something. pre-commit would fail the hook anyway because files changed, but an explicit exit code makes the same command useful in CI, where "needed fixing" should fail the build.
  • Do not print anything on success. pre-commit shows "Passed"; extra output is noise on every commit.
  • Never touch files you were not given. A hook that rewrites other files surprises users and confuses pre-commit's change detection.

UX considerations

Your CLI in someone else's commit Terminal output of a git commit where pre-commit runs a third-party CLI hook that fails on one file and passes on the rest. Your CLI in someone else's commit bash $ git commit -m "add deploy config" mytool config check.....................................Failed - hook id: mytool-check - exit code: 1 deploy/prod.yaml:12: unknown key "replcas" (did you mean "replicas"?) Output format matters: file, line and a fix suggestion is what makes a hook welcome.
  • Offer check and fix as separate hooks. Some teams want automatic fixes on commit; others want a failing check and a manual fix. Two hook IDs let each choose.
  • Keep startup fast. Your command runs on every commit that touches matching files. A CLI that takes 800 ms to import is felt constantly; lazy imports matter here, as covered in lazy-loading subcommands for faster startup.
  • Suggest the fix in the message. "unknown key 'replcas' (did you mean 'replicas'?)" turns a failed commit into a ten-second correction.
  • Document configuration. Users pass extra flags through args: in their configuration (args: [--strict]). List the supported flags in the README section about pre-commit.

Releasing the hook

Users pin rev to a tag, and pre-commit autoupdate moves them to your newest tag. So:

  • Every release tag is a hook release. The .pre-commit-hooks.yaml at that tag is what users get. Tag releases as described in automating releases from git tags.
  • Treat hook IDs and default behaviour as public API. Adding a hook is a minor change; renaming one, or making a check stricter by default, is breaking for users who update automatically. Follow the policy in semantic versioning policy for CLI tools.
  • Keep install dependencies light. Every user's first commit after updating builds your environment. Heavy dependencies slow that down noticeably.

Testing the behaviour

pre-commit can run hooks straight from a working tree with try-repo, which is the fastest way to test a hook definition before tagging:

# In a scratch repository with some sample config files staged:
git init /tmp/hook-test && cd /tmp/hook-test
mkdir deploy && printf 'replcas: 3\n' > deploy/app.yaml && git add .
pre-commit try-repo ~/src/mytool mytool-check --all-files

For automated tests, test the command's contract directly — exit codes, output format, and that fixers only touch given files — with CliRunner:

# tests/test_hook_contract.py
from typer.testing import CliRunner

from mytool.cli import app

runner = CliRunner()


def test_check_reports_file_and_line(tmp_path):
    bad = tmp_path / "app.yaml"
    bad.write_text("replcas: 3\n")
    result = runner.invoke(app, ["check", str(bad)])
    assert result.exit_code == 1
    assert f"{bad}:1:" in result.output


def test_check_is_silent_on_success(tmp_path):
    good = tmp_path / "app.yaml"
    good.write_text("replicas: 3\n")
    result = runner.invoke(app, ["check", str(good)])
    assert (result.exit_code, result.output) == (0, "")


def test_fix_exits_1_only_when_it_changes_something(tmp_path):
    f = tmp_path / "app.yaml"
    f.write_text("replicas:   3\n")
    assert runner.invoke(app, ["fix", str(f)]).exit_code == 1
    assert runner.invoke(app, ["fix", str(f)]).exit_code == 0     # already canonical

The idempotence test in the last function matters: a fixer whose output is not stable under a second run makes pre-commit fail forever.

Conclusion

Publishing a pre-commit hook turns your CLI into something teams adopt with three lines of YAML. Add a .pre-commit-hooks.yaml with stable IDs, language: python and tight file filters; make the command accept many files, print file:line: message, stay silent on success and exit non-zero when it finds or fixes something; keep it fast; and treat hook IDs and defaults as public API released through tags. Test with try-repo and a handful of contract tests, and your tool starts running on every commit in every repository that wants it.

Frequently asked questions

Should the hook use language: system instead?

Only for hooks that must run inside the user's own environment, such as a type checker that needs their dependencies. For a published tool, language: python is what makes it work anywhere without installation instructions.

How do I pass configuration to the hook?

Read a config file from the repository root (your tool's usual discovery rules apply, since pre-commit runs from the root), and accept flags through the user's args:. Avoid requiring environment variables; hooks run in varied environments.

My tool needs to see all files, not just staged ones. Is that possible?

Set pass_filenames: false and always_run: true, and let your tool discover files itself. Use sparingly: it runs on every commit regardless of what changed, so it must be fast.

Should the hook repository be separate from the CLI repository?

Usually not. Keeping .pre-commit-hooks.yaml in the CLI's own repository means every release tag is automatically a hook release and the hook always runs the matching version of the tool. A separate "mirror" repository makes sense only when the tool's repository is huge or slow to clone, since pre-commit clones it for every user.

Can I publish hooks for tools that are not Python?

pre-commit supports many languages, including prebuilt binaries via language: system or container images via language: docker_image. For a Python CLI, language: python is the simplest and most portable.