Skip to content

grip_hook.config

grip_hook.config

Configuration loading.

Precedence, highest first:

  1. Command-line flags.
  2. GRIP_* environment variables.
  3. .grip.toml at the repository root.
  4. [tool.grip] in pyproject.toml at the repository root.
  5. Built-in defaults.

AGENT_PROVIDERS module-attribute

AGENT_PROVIDERS: tuple[tuple[str, str], ...] = (('claude-code', 'claude'), ('codex', 'codex'), ('gemini', 'gemini'))

Coding-agent providers auto tries, in order, with the command each one needs.

Config dataclass

Resolved grip configuration.

Source code in src/grip_hook/config.py
@dataclass(frozen=True, slots=True)
class Config:
    """Resolved grip configuration."""

    passing_score: int = DEFAULT_PASSING_SCORE
    """Minimum Grip Score (0-100) required to let the commit or push through."""

    provider: str = AUTO_PROVIDER
    """Provider name: ``auto``, ``anthropic``, ``openai``, ``ollama``, ``claude-code``,
    ``codex``, ``gemini`` or ``fake``. ``auto`` picks an installed coding agent, else
    ``anthropic``; see :func:`auto_provider`."""

    model: str = ""
    """Model identifier passed to the provider. Empty means the provider's default."""

    effort: str = "medium"
    """Reasoning effort for providers that support it. ``none`` disables the parameter."""

    api_key_env: str = ""
    """Environment variable holding the API key. Empty means the provider's default."""

    base_url: str = ""
    """Override the provider's API base URL (Ollama, proxies, OpenAI-compatible servers)."""

    difficulty: Difficulty = Difficulty.NORMAL
    """How demanding the questions and grading are."""

    max_diff_bytes: int = 200_000
    """Diffs larger than this are truncated before being sent to the provider."""

    exclude: tuple[str, ...] = DEFAULT_EXCLUDES
    """Path globs excluded from the diff (lockfiles, generated code)."""

    remember_passes_hours: float = 24.0
    """How long a passed diff stays valid without re-quizzing. ``0`` disables."""

    cache_hours: float = 24.0
    """How long the questions and boards written for a diff are reused when the same diff
    is quizzed again, so a retry after a failed quiz costs one grading call. ``0`` asks
    the model every time; ``--fresh`` does so once."""

    hook_mode: str = "quiz"
    """What the git hook asks for: ``quiz`` (questions in the terminal) or ``whiteboard``
    (defend a board of the change in the browser, as ``grip whiteboard --defend`` does).
    The whiteboard falls back to the terminal quiz when no browser can be opened."""

    require_tty: bool = False
    """Fail instead of skipping when no interactive terminal is available."""

    fail_open: bool = False
    """Let the commit or push through when the provider errors out instead of blocking."""

    timeout: float = 120.0
    """Per-request timeout in seconds for provider calls."""

    def validate(self) -> Config:
        """Raise :class:`ConfigError` for out-of-range values and return ``self``."""
        if not 0 <= self.passing_score <= 100:
            raise ConfigError(f"passing_score must be between 0 and 100, got {self.passing_score}")
        if self.max_diff_bytes <= 0:
            raise ConfigError("max_diff_bytes must be positive")
        if self.remember_passes_hours < 0:
            raise ConfigError("remember_passes_hours must be zero or positive")
        if self.cache_hours < 0:
            raise ConfigError("cache_hours must be zero or positive")
        if self.timeout <= 0:
            raise ConfigError("timeout must be positive")
        if not self.provider:
            raise ConfigError("provider must not be empty")
        if self.hook_mode not in HOOK_MODES:
            allowed = ", ".join(HOOK_MODES)
            raise ConfigError(f"hook_mode must be one of {allowed}, got {self.hook_mode!r}")
        return self

    @property
    def provider_name(self) -> str:
        """The provider that will actually run: ``auto`` resolved on this machine."""
        name = self.provider.lower()
        return auto_provider() if name == AUTO_PROVIDER else name

passing_score class-attribute instance-attribute

passing_score: int = DEFAULT_PASSING_SCORE

Minimum Grip Score (0-100) required to let the commit or push through.

provider class-attribute instance-attribute

provider: str = AUTO_PROVIDER

Provider name: auto, anthropic, openai, ollama, claude-code, codex, gemini or fake. auto picks an installed coding agent, else anthropic; see :func:auto_provider.

model class-attribute instance-attribute

model: str = ''

Model identifier passed to the provider. Empty means the provider's default.

effort class-attribute instance-attribute

effort: str = 'medium'

Reasoning effort for providers that support it. none disables the parameter.

api_key_env class-attribute instance-attribute

api_key_env: str = ''

Environment variable holding the API key. Empty means the provider's default.

base_url class-attribute instance-attribute

base_url: str = ''

Override the provider's API base URL (Ollama, proxies, OpenAI-compatible servers).

difficulty class-attribute instance-attribute

difficulty: Difficulty = Difficulty.NORMAL

How demanding the questions and grading are.

max_diff_bytes class-attribute instance-attribute

max_diff_bytes: int = 200000

Diffs larger than this are truncated before being sent to the provider.

exclude class-attribute instance-attribute

exclude: tuple[str, ...] = DEFAULT_EXCLUDES

Path globs excluded from the diff (lockfiles, generated code).

remember_passes_hours class-attribute instance-attribute

remember_passes_hours: float = 24.0

How long a passed diff stays valid without re-quizzing. 0 disables.

cache_hours class-attribute instance-attribute

cache_hours: float = 24.0

How long the questions and boards written for a diff are reused when the same diff is quizzed again, so a retry after a failed quiz costs one grading call. 0 asks the model every time; --fresh does so once.

hook_mode class-attribute instance-attribute

hook_mode: str = 'quiz'

What the git hook asks for: quiz (questions in the terminal) or whiteboard (defend a board of the change in the browser, as grip whiteboard --defend does). The whiteboard falls back to the terminal quiz when no browser can be opened.

require_tty class-attribute instance-attribute

require_tty: bool = False

Fail instead of skipping when no interactive terminal is available.

fail_open class-attribute instance-attribute

fail_open: bool = False

Let the commit or push through when the provider errors out instead of blocking.

timeout class-attribute instance-attribute

timeout: float = 120.0

Per-request timeout in seconds for provider calls.

provider_name property

provider_name: str

The provider that will actually run: auto resolved on this machine.

validate

validate() -> Config

Raise :class:ConfigError for out-of-range values and return self.

Source code in src/grip_hook/config.py
def validate(self) -> Config:
    """Raise :class:`ConfigError` for out-of-range values and return ``self``."""
    if not 0 <= self.passing_score <= 100:
        raise ConfigError(f"passing_score must be between 0 and 100, got {self.passing_score}")
    if self.max_diff_bytes <= 0:
        raise ConfigError("max_diff_bytes must be positive")
    if self.remember_passes_hours < 0:
        raise ConfigError("remember_passes_hours must be zero or positive")
    if self.cache_hours < 0:
        raise ConfigError("cache_hours must be zero or positive")
    if self.timeout <= 0:
        raise ConfigError("timeout must be positive")
    if not self.provider:
        raise ConfigError("provider must not be empty")
    if self.hook_mode not in HOOK_MODES:
        allowed = ", ".join(HOOK_MODES)
        raise ConfigError(f"hook_mode must be one of {allowed}, got {self.hook_mode!r}")
    return self

auto_provider

auto_provider() -> str

What provider = "auto" means on this machine.

A coding agent that is installed (and so, usually, signed in) needs no API key and adds no bill, so the first of Claude Code, Codex and Gemini CLI on PATH wins; without one, the Anthropic API.

Source code in src/grip_hook/config.py
def auto_provider() -> str:
    """What ``provider = "auto"`` means on this machine.

    A coding agent that is installed (and so, usually, signed in) needs no API key and adds
    no bill, so the first of Claude Code, Codex and Gemini CLI on ``PATH`` wins; without
    one, the Anthropic API.
    """
    for name, command in AGENT_PROVIDERS:
        if shutil.which(command):
            return name
    return "anthropic"

load_config

load_config(root: Path | None, *, env: dict[str, str] | None = None, overrides: dict[str, Any] | None = None) -> Config

Build the effective :class:Config for a repository.

Parameters:

Name Type Description Default
root Path | None

Repository root (where .grip.toml / pyproject.toml live), or None.

required
env dict[str, str] | None

Environment mapping. Defaults to :data:os.environ.

None
overrides dict[str, Any] | None

Values from command-line flags. None entries are ignored.

None
Source code in src/grip_hook/config.py
def load_config(
    root: Path | None,
    *,
    env: dict[str, str] | None = None,
    overrides: dict[str, Any] | None = None,
) -> Config:
    """Build the effective :class:`Config` for a repository.

    Args:
        root: Repository root (where ``.grip.toml`` / ``pyproject.toml`` live), or ``None``.
        env: Environment mapping. Defaults to :data:`os.environ`.
        overrides: Values from command-line flags. ``None`` entries are ignored.
    """
    cfg = Config()
    if root is not None:
        pyproject = root / "pyproject.toml"
        if pyproject.is_file():
            tool = _read_toml(pyproject).get("tool", {})
            if isinstance(tool, dict) and isinstance(tool.get("grip"), dict):
                cfg = _apply(cfg, tool["grip"], str(pyproject))
        grip_toml = root / ".grip.toml"
        if grip_toml.is_file():
            cfg = _apply(cfg, _read_toml(grip_toml), str(grip_toml))

    environ = os.environ if env is None else env
    env_raw = {
        key[len(ENV_PREFIX) :].lower(): value
        for key, value in environ.items()
        if key.startswith(ENV_PREFIX) and key[len(ENV_PREFIX) :].lower() in _FIELD_NAMES
    }
    cfg = _apply(cfg, env_raw, "environment")

    if overrides:
        cfg = _apply(cfg, {k: v for k, v in overrides.items() if v is not None}, "command line")
    return cfg.validate()

render_template

render_template(*, provider: str, passing_score: int = DEFAULT_PASSING_SCORE, difficulty: str = 'normal') -> str

The commented .grip.toml that grip init writes.

Source code in src/grip_hook/config.py
def render_template(
    *, provider: str, passing_score: int = DEFAULT_PASSING_SCORE, difficulty: str = "normal"
) -> str:
    """The commented ``.grip.toml`` that ``grip init`` writes."""
    cfg = _apply(
        Config(),
        {"provider": provider, "passing_score": passing_score, "difficulty": difficulty},
        "grip init",
    ).validate()
    return TEMPLATE.format(
        passing_score=cfg.passing_score, difficulty=cfg.difficulty.value, provider=cfg.provider
    )

describe

describe(cfg: Config) -> list[tuple[str, str]]

Return (name, value) pairs for display.

Source code in src/grip_hook/config.py
def describe(cfg: Config) -> list[tuple[str, str]]:
    """Return ``(name, value)`` pairs for display."""
    rows: list[tuple[str, str]] = []
    for f in fields(cfg):
        value = getattr(cfg, f.name)
        if isinstance(value, tuple):
            text = ", ".join(value) if value else "(none)"
        elif isinstance(value, Difficulty):
            text = value.value
        else:
            text = str(value) if value != "" else "(provider default)"
        if f.name == "provider" and value == AUTO_PROVIDER:
            text = f"{AUTO_PROVIDER} ({cfg.provider_name})"
        rows.append((f.name, text))
    return rows