Settings & configuration

Give every hook handler a typed, per-project configuration object with defaults and environment overrides, so config-driven decisions stop relying on global imports.

Different projects ship different test runners, lint allowlists, and version-control conventions. You want one typed configuration object available in every handler, with sensible defaults and HOOKS_* environment-variable overrides — not a module-level singleton or a scatter of os.environ reads. Subclass HooksSettings to declare your knobs, then read the resolved values off evt.ctx.c inside the handler.

"""Enforce a project's configured test runner using typed per-project settings."""

from __future__ import annotations

from typing import cast

from captain_hook import (
    Allow,
    BaseHookEvent,
    Event,
    HookResult,
    HooksSettings,
    Input,
    Tool,
    on,
)


class ProjectSettings(HooksSettings):
    test_command: str = "pytest"
    require_tests_after_edit: bool = True
    excluded_dirs: tuple[str, ...] = ("vendor", "node_modules")


@on(
    Event.PreToolUse,
    only_if=[Tool("Bash")],
    tests={
        Input(tool="Bash", command="ls -la"): Allow(),
        Input(tool="Bash", command="ruff check ."): Allow(),
    },
)
def enforce_test_command(evt: BaseHookEvent) -> HookResult | None:
    settings = cast(ProjectSettings, evt.ctx.c)
    if (cmd := evt.command).raw and cmd.q.runs("pytest") and settings.test_command != "pytest":
        return evt.block(f"BLOCKED: use the project's configured test runner instead. Run: {settings.test_command}")
    return None
NoteVerified by replay

The block fires only when the resolved test_command setting differs from pytest, so the live config-driven decision is exercised by real settings resolution rather than the inline tests, which assert the deterministic narrowing and allow paths.

What it catches

pytest tests/    # project configured a non-pytest test_command — use the configured runner instead

What it allows

ls -la           # not a test invocation
ruff check .     # lint, not a test invocation

The block / allow split mirrors the hook inline tests, so it stays true as the hook evolves.

Run it yourself

uvx capt-hook --hooks docs/examples test

See also