Settings & Configuration

Different projects have different test runners, different lint allowlists, different VCS conventions. You want a single typed configuration object available in every hook handler, with sensible defaults and HOOKS_* environment-variable overrides.

from __future__ import annotations

from typing import cast

from captain_hook import (
    BaseHookEvent,
    Event,
    HookResult,
    HooksSettings,
    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")])
def enforce_test_command(evt: BaseHookEvent) -> HookResult | None:
    settings = cast(ProjectSettings, evt.ctx.c)
    if (cl := evt.command_line) and cl.q.runs("pytest") and settings.test_command != "pytest":
        return evt.block(
            f"BLOCKED: use the project's configured test runner instead. "
            f"Run: {settings.test_command}"
        )
    return None

What to learn: Subclass HooksSettings (a pydantic_settings.BaseSettings) to declare your project’s knobs. Defaults live on the class; overrides come from the environment via the pydantic-settings machinery. At dispatch time, every handler can read the resolved settings as evt.ctx.c (also exposed as evt.ctx.conf and evt.ctx.settings) — no global imports, no module-level config singleton.