diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3010567..3150440 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -233,6 +233,28 @@ repos:
priority: 10
args: [--pytest-test-first]
+ - repo: local
+ hooks:
+
+ # Validate JSON5 file syntax (PEP 723 script; pyjson5 resolved at runtime)
+ - id: check-json5
+ name: "[check] json5 syntax"
+ stages: [pre-commit, manual]
+ priority: 10
+ entry: scripts/hooks/check-json5.py
+ language: python
+ files: .*\.json5$
+
+ # Validate Pkl file syntax via `pkl eval` (no-op if `pkl` is not installed)
+ - id: check-pkl
+ name: "[check] pkl syntax"
+ stages: [pre-commit, manual]
+ priority: 10
+ entry: scripts/hooks/check-pkl.py
+ language: python
+ files: .*\.pkl$
+ types: [file]
+
# ========================================================================================
# Text/whitespace fixers
#
@@ -279,6 +301,7 @@ repos:
# - Priority 35 shell scripts shfmt
# json (.claude) pretty-format-json
# toml taplo
+ # pkl format-pkl
# markdown mdformat
# python ruff --fix-only
#
@@ -290,8 +313,8 @@ repos:
# Language-agnostic auto-sorting between two markers
- id: keep-sorted
- stages: [pre-commit]
name: "[format] sorting (keep-sorted)"
+ stages: [pre-commit]
priority: 30
- repo: https://github.com/scop/pre-commit-shfmt
@@ -331,6 +354,19 @@ repos:
stages: [pre-commit]
priority: 35
+ - repo: local
+ hooks:
+
+ # Pkl file auto-formatting (no-op if `pkl` is not installed); not run in CI
+ - id: format-pkl
+ name: "[format] pkl (pkl format)"
+ stages: [pre-commit]
+ priority: 35
+ entry: scripts/hooks/format-pkl.py
+ language: python
+ files: .*\.pkl$
+ types: [file]
+
- repo: https://github.com/hukkin/mdformat
rev: 1.0.0
hooks:
diff --git a/scripts/hooks/README.md b/scripts/hooks/README.md
new file mode 100644
index 0000000..1d340f3
--- /dev/null
+++ b/scripts/hooks/README.md
@@ -0,0 +1,395 @@
+[Project Root](../../README.md) > [Scripts](../README.md) > **Hook Scripts**
+
+---
+
+# Hook Scripts
+
+Custom pre-commit hook scripts for checks and transforms not covered by standard hooks. All four scripts follow the same
+interface contract as `pre-commit`: they accept a list of filenames as positional arguments and exit `0` on success or
+non-zero on failure. They are wired into [`.pre-commit-config.yaml`](../../.pre-commit-config.yaml) as `local` repo
+hooks and run via `prek` / `make pre`. Each is a self-contained PEP 723 script, so `uv run` resolves any dependencies in
+isolation.
+
+
+
+- [Scripts](#scripts)
+- [Pre-Commit Wiring](#pre-commit-wiring)
+- [`check-json5.py`](#check-json5py)
+ - [Synopsis](#synopsis)
+ - [Options](#options)
+ - [Behavior](#behavior)
+ - [Exit Codes](#exit-codes)
+ - [Pre-Commit Integration](#pre-commit-integration)
+- [`check-pkl.py`](#check-pklpy)
+ - [Synopsis](#synopsis-1)
+ - [Options](#options-1)
+ - [Behavior](#behavior-1)
+ - [Exit Codes](#exit-codes-1)
+ - [Pre-Commit Integration](#pre-commit-integration-1)
+- [`format-pkl.py`](#format-pklpy)
+ - [Synopsis](#synopsis-2)
+ - [Options](#options-2)
+ - [Behavior](#behavior-2)
+ - [Exit Codes](#exit-codes-2)
+ - [Pre-Commit Integration](#pre-commit-integration-2)
+- [`sort-json-array.py`](#sort-json-arraypy)
+ - [Synopsis](#synopsis-3)
+ - [Options](#options-3)
+ - [Behavior](#behavior-3)
+ - [Exit Codes](#exit-codes-3)
+ - [Pre-Commit Integration](#pre-commit-integration-3)
+
+
+
+## Scripts
+
+| Script | What it does | Hook id | Priority |
+| ------------------------------------------ | ------------------------------------------------------ | ----------------- | ----------------- |
+| [`check-json5.py`](#check-json5py) | Validate JSON5 file syntax via `pyjson5` | `check-json5` | 10 (check phase) |
+| [`check-pkl.py`](#check-pklpy) | Validate Pkl files by running `pkl eval --format yaml` | `check-pkl` | 10 (check phase) |
+| [`format-pkl.py`](#format-pklpy) | Auto-format Pkl files via `pkl format` | `format-pkl` | 35 (format phase) |
+| [`sort-json-array.py`](#sort-json-arraypy) | Sort a top-level JSON array of objects by a key | `sort-json-array` | 30 (sort phase) |
+
+> [!NOTE]
+> this repository does not currently contain any `.json5`, `.pkl`, or `keybindings.json` files, so all four hooks are
+> staged for future use. Each is scoped so it matches nothing today and fires automatically the moment a matching file
+> lands.
+
+## Pre-Commit Wiring
+
+All four hooks are declared as `repo: local` entries in [`.pre-commit-config.yaml`](../../.pre-commit-config.yaml),
+slotted into the repository's priority scheme. The relevant stanzas are:
+
+```yaml
+ - repo: local
+ hooks:
+ - id: check-json5
+ name: '[check] json5 syntax'
+ priority: 10
+ entry: scripts/hooks/check-json5.py
+ language: python
+ files: .*\.json5$
+
+ - id: check-pkl
+ name: '[check] pkl syntax'
+ priority: 10
+ entry: scripts/hooks/check-pkl.py
+ language: python
+ files: .*\.pkl$
+ types: [file]
+
+ - repo: local
+ hooks:
+ - id: sort-json-array
+ name: '[format] sorting (keybindings.json)'
+ priority: 30
+ stages: [pre-commit]
+ entry: scripts/hooks/sort-json-array.py
+ language: python
+ files: .*keybindings\.json$
+ args: [--key, command]
+
+ - repo: local
+ hooks:
+ - id: format-pkl
+ name: '[format] pkl (pkl format)'
+ priority: 35
+ stages: [pre-commit] # skipped in CI
+ entry: scripts/hooks/format-pkl.py
+ language: python
+ files: .*\.pkl$
+ types: [file]
+```
+
+**Priority scheme** used across the whole config:
+
+| Priority band | Phase | Notes |
+| ------------- | ----------------- | ------------------------------------------------------------------------- |
+| 0–6 | Dependency sync | `uv-lock`, `uv-sync`, `uv-export`, `sync-with-uv`, `sync-pre-commit-deps` |
+| 10 | Read-only checks | Run in parallel; `check-json5`, `check-pkl` live here |
+| 20–23 | Whitespace fixers | `fix-byte-order-marker`, `end-of-file-fixer`, `trailing-whitespace`, etc. |
+| 30–35 | Formatters | `keep-sorted`, `sort-json-array` (30); `shfmt`, `format-pkl`, etc. (35) |
+| 40 | Linters | `ruff`, `mypy`, `yamllint`, `shellcheck`, `codespell` |
+
+The `format-pkl` hook carries `stages: [pre-commit]` so it is skipped when the config is run in CI (`manual` stage).
+`check-pkl` and `check-json5` run in all stages. `sort-json-array` runs at priority 30 (before the priority-35
+`pretty-format-json` formatter) so a reordered array is normalized once and surfaces in the diff to re-stage rather than
+landing silently.
+
+Both Pkl hooks are tolerant of environments where `pkl` is not installed: by default they print a warning and exit `0`.
+Pass `--require-pkl` to make the missing binary a hard failure.
+
+---
+
+## `check-json5.py`
+
+### Synopsis
+
+Validates that every supplied `.json5` file parses successfully via `pyjson5`. Declared as a PEP 723 inline-script with
+`pyjson5` as a dependency so `uv run` can execute it in isolation.
+
+```
+python3 scripts/hooks/check-json5.py [OPTIONS] [FILENAMES...]
+```
+
+### Options
+
+| Flag | Description | Default |
+| ----------------- | ------------------------------------------------- | ------- |
+| `FILENAMES` | One or more `.json5` files to validate | — |
+| `-v`, `--verbose` | Print a per-file `INFO` line for every valid file | off |
+| `-h`, `--help` | Show help and exit | — |
+
+### Behavior
+
+For each filename:
+
+1. Opens the file and calls `pyjson5.load()`.
+2. On `Json5DecoderException` — prints `ERROR ` followed by the exception class and message (indented),
+ records failure.
+3. On any other exception — prints `ERROR ` followed by `Unable to decode JSON5: `.
+4. On success with `--verbose` — prints `INFO ` followed by `Valid JSON5`.
+
+All output is ANSI-colored. The script exits non-zero if any file fails.
+
+### Exit Codes
+
+| Code | Meaning |
+| ---- | -------------------------------------- |
+| `0` | All files valid (or no files supplied) |
+| `1` | One or more files failed to parse |
+
+### Pre-Commit Integration
+
+```yaml
+ - id: check-json5
+ entry: scripts/hooks/check-json5.py
+ language: python
+ files: .*\.json5$
+```
+
+pre-commit passes all staged `.json5` files as positional arguments. The `language: python` runtime means pre-commit
+manages the `pyjson5` dependency automatically using the inline PEP 723 metadata.
+
+---
+
+## `check-pkl.py`
+
+### Synopsis
+
+Validates Pkl configuration files by running `pkl eval --format yaml` against each file. If `pkl` is not found on `PATH`
+the script warns and exits `0` (non-blocking) unless `--require-pkl` is set.
+
+```
+python3 scripts/hooks/check-pkl.py [OPTIONS] [FILENAMES...]
+```
+
+### Options
+
+#### Positional
+
+| Argument | Description |
+| ----------- | ------------------------------------ |
+| `FILENAMES` | One or more `.pkl` files to validate |
+
+#### Pkl executable options
+
+| Flag | Description | Default |
+| ------------------- | ------------------------------------------------------------------ | ---------------------------- |
+| `--require-pkl` | Exit non-zero if `pkl` binary not found (default: warn and exit 0) | off |
+| `--executable PATH` | Path to `pkl` binary | `pkl` (resolved from `PATH`) |
+
+#### Output options
+
+| Flag | Description | Default |
+| ----------------------------- | ------------------------------------------------------- | -------- |
+| `-v`, `--verbose` | Print per-file `INFO` line for valid files | off |
+| `--dump-yaml` | Print the YAML output generated by `pkl eval` to stdout | off |
+| `--color never\|auto\|always` | ANSI color control (forwarded to `pkl eval --color`) | `always` |
+
+#### Execution options
+
+| Flag | Description | Default |
+| --------------------------------- | --------------------------------------------------------------- | ------- |
+| `-t SECONDS`, `--timeout SECONDS` | Per-file evaluation timeout (forwarded to `pkl eval --timeout`) | none |
+
+#### Package options
+
+| Flag | Description | Default |
+| ------------ | ------------------------------------------------------------- | ------- |
+| `--no-cache` | Disable Pkl package cache (passes `--no-cache` to `pkl eval`) | off |
+
+#### Other
+
+| Flag | Description |
+| -------------- | ------------------ |
+| `-h`, `--help` | Show help and exit |
+
+### Behavior
+
+1. Checks `shutil.which(args.executable)`. If not found: warn and return `0`, or error and return `1` if `--require-pkl`
+ is set.
+2. Builds the base command: `pkl eval --format yaml [--color ...] [--timeout ...] [--no-cache]`.
+3. For each file, runs the command via `subprocess.run(check=True, capture_output=True)`.
+4. On `CalledProcessError` — prints `ERROR ` with the stderr content (strips the redundant `–– Pkl Error ––`
+ header that `pkl` emits).
+5. On success with `--verbose` — prints `INFO ` followed by `Valid Pkl`.
+6. With `--dump-yaml` — prints the evaluated YAML output below the per-file status line.
+
+### Exit Codes
+
+| Code | Meaning |
+| ---- | --------------------------------------------------------------------------------------- |
+| `0` | All files valid, or `pkl` not installed (without `--require-pkl`), or no files supplied |
+| `1` | One or more files failed validation, or `pkl` not found with `--require-pkl` |
+
+### Pre-Commit Integration
+
+```yaml
+ - id: check-pkl
+ entry: scripts/hooks/check-pkl.py
+ language: python
+ files: .*\.pkl$
+ types: [file]
+```
+
+---
+
+## `format-pkl.py`
+
+### Synopsis
+
+Auto-formats Pkl files in-place using `pkl format`. Detects files that need formatting (exit code `11` from
+`pkl format --diff-name-only`) and rewrites them with `--write`. Reports `MODIFIED` or `UNMODIFIED` per file. If `pkl`
+is not on `PATH`, warns and exits `0` unless `--require-pkl` is set.
+
+```
+python3 scripts/hooks/format-pkl.py [OPTIONS] [FILENAMES...]
+```
+
+### Options
+
+#### Positional
+
+| Argument | Description |
+| ----------- | ---------------------------------- |
+| `FILENAMES` | One or more `.pkl` files to format |
+
+#### Pkl executable options
+
+| Flag | Description | Default |
+| ------------------- | --------------------------------------- | ---------------------------- |
+| `--require-pkl` | Exit non-zero if `pkl` binary not found | off |
+| `--executable PATH` | Path to `pkl` binary | `pkl` (resolved from `PATH`) |
+
+#### Grammar options
+
+| Flag | Description | Default |
+| ------------------------ | ---------------------------------------------------------------- | ------- |
+| `--grammar-version 1\|2` | Pkl grammar compatibility version (`1` = 0.25–0.29, `2` = 0.30+) | `2` |
+
+#### Output options
+
+| Flag | Description | Default |
+| ----------------- | -------------------------------------------------------- | ------- |
+| `-v`, `--verbose` | Print `UNMODIFIED` line for files that needed no changes | off |
+| `-h`, `--help` | Show help and exit | — |
+
+### Behavior
+
+1. Checks `shutil.which(args.executable)`. If not found: warn and return `0`, or error and return `1` if
+ `--require-pkl`.
+2. Builds the base command: `pkl format --diff-name-only [--grammar-version N]`.
+3. For each file:
+ - Runs `pkl format --diff-name-only `. Exit `0` means already formatted; exit `11` means the file needs
+ reformatting.
+ - On exit `11` — re-runs with `--write` to reformat in-place, prints `MODIFIED `.
+ - On any other non-zero exit — prints `ERROR ` with stderr (suppressing the generic
+ `An error occurred during formatting.` line), and records the exit code.
+4. With `--verbose`, prints `UNMODIFIED ` for files that required no changes.
+
+The exit code of the hook is the maximum non-zero return code seen across all files, or `0` if all succeeded.
+
+### Exit Codes
+
+| Code | Meaning |
+| -------- | ----------------------------------------------------------------------------------------------------- |
+| `0` | All files formatted or already correct, or `pkl` not installed (without `--require-pkl`), or no files |
+| `1` | `pkl` not found with `--require-pkl`, or an unexpected exception occurred |
+| `N` (>0) | Maximum `pkl format` exit code across all failing files |
+
+### Pre-Commit Integration
+
+```yaml
+ - id: format-pkl
+ entry: scripts/hooks/format-pkl.py
+ language: python
+ files: .*\.pkl$
+ types: [file]
+ stages: [pre-commit] # not run in CI
+```
+
+The `stages: [pre-commit]` restriction means this hook only runs during interactive `git commit` (and `make pre`). CI
+pipelines run `check-pkl` (read-only) instead of attempting in-place formatting.
+
+---
+
+## `sort-json-array.py`
+
+### Synopsis
+
+Sorts a top-level JSON array of objects in-place by the value of a chosen object key. Pure-stdlib PEP 723 script (no
+dependencies). The file is rewritten only when the sorted result differs from the original.
+
+```
+python3 scripts/hooks/sort-json-array.py [OPTIONS] [FILENAMES...]
+```
+
+### Options
+
+| Flag | Description | Default |
+| ----------------- | ----------------------------------------------------- | --------- |
+| `FILENAMES` | One or more JSON files (top-level array of objects) | — |
+| `-k`, `--key` | Object key whose value each array entry is sorted by | `command` |
+| `--indent` | Number of spaces to indent the rewritten JSON by | `4` |
+| `-v`, `--verbose` | Print a per-file `INFO` line for already-sorted files | off |
+| `-h`, `--help` | Show help and exit | — |
+
+### Behavior
+
+For each filename:
+
+1. Reads and parses the file as JSON.
+2. On a decode error, or if the top level is not an array of objects — prints `ERROR ` with the reason,
+ records failure.
+3. Stably sorts the array by `entry.get(key, '')` (entries missing the key sort first; equal keys keep their original
+ relative order).
+4. Re-serializes and, if the result differs, rewrites the file in-place and prints `INFO ` /
+ `Sorted array by ''`.
+5. If already sorted — with `--verbose`, prints `INFO ` / `Already sorted by ''`.
+
+### Exit Codes
+
+| Code | Meaning |
+| ---- | --------------------------------------------------------------------------------- |
+| `0` | Every file already sorted (or no files supplied) |
+| `1` | One or more files were rewritten, failed to parse, or are not an array of objects |
+
+### Pre-Commit Integration
+
+```yaml
+ - id: sort-json-array
+ entry: scripts/hooks/sort-json-array.py
+ language: python
+ args: [--key, command]
+ files: .*keybindings\.json$
+```
+
+Scoped to any `keybindings.json`, this keeps such a file's array ordered by each entry's `command` value. It runs at
+priority 30 (before the priority-35 `pretty-format-json` formatter), so a reordered file is normalized once and surfaces
+in the diff to re-stage rather than landing silently. The default `--key command` also makes the hook usable as-is for
+other keybindings-style JSON.
+
+---
+
+> **See also**: [`scripts/env/`](../env/README.md) for the Python environment utilities used during development.
diff --git a/scripts/hooks/check-json5.py b/scripts/hooks/check-json5.py
new file mode 100755
index 0000000..07c03c3
--- /dev/null
+++ b/scripts/hooks/check-json5.py
@@ -0,0 +1,342 @@
+#!/usr/bin/env python3
+"""
+Pre-commit hook to validate JSON5 file syntax.
+"""
+# /// script
+# requires-python = ">= 3.9"
+# dependencies = [
+# "pyjson5",
+# ]
+# ///
+from __future__ import annotations
+
+import argparse
+import functools
+import re
+import sys
+import textwrap
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any
+from typing import Union
+from typing import cast
+
+try:
+ import pyjson5
+except (ModuleNotFoundError, NameError, ImportError):
+ print('`pyjson5` package not installed. Please install `pyjson5` before running hook')
+ sys.exit(1)
+
+
+# ==========================
+# Constants/defaults/globals
+# ==========================
+
+
+VERBOSE: bool = False
+
+
+# ===============
+# Types/protocols
+# ===============
+
+
+PathLike = Union[str, Path]
+StyleColor = Union[int, tuple[int, int, int], str]
+
+
+# ==================
+# Formatting/logging
+# ==================
+
+
+ANSI_COLOR: dict[str, int] = {
+ 'black': 30, 'bright_black': 90,
+ 'red': 31, 'bright_red': 91,
+ 'green': 32, 'bright_green': 92,
+ 'yellow': 33, 'bright_yellow': 93,
+ 'blue': 34, 'bright_blue': 94,
+ 'magenta': 35, 'bright_magenta': 95,
+ 'cyan': 36, 'bright_cyan': 96,
+ 'white': 37, 'bright_white': 97,
+ 'reset': 39,
+}
+ANSI_RESET_ALL = '\033[0m'
+
+
+def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
+ """
+ Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string.
+
+ :param _color: color as a named string, 256-color int, or ``(r, g, b)`` tuple
+ :param offset: offset added to the base code (0 for foreground, 10 for background)
+ :return: ANSI SGR parameter fragment (e.g. ``"32"`` or ``"38;2;255;0;0"``)
+ """
+ if isinstance(_color, int):
+ return f'{38 + offset};5;{_color:d}'
+ if isinstance(_color, (tuple, list)):
+ r, g, b = _color
+ return f'{38 + offset};2;{r:d};{g:d};{b:d}'
+ _color = cast('str', _color)
+ return str(ANSI_COLOR[_color] + offset)
+
+
+def style(
+ text: Any = '',
+ *,
+ fg: StyleColor | None = None,
+ bg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ underline: bool | None = None,
+ overline: bool | None = None,
+ italic: bool | None = None,
+ blink: bool | None = None,
+ reverse: bool | None = None,
+ strikethrough: bool | None = None,
+ reset: bool = True,
+) -> str:
+ """
+ Style text with ANSI escape codes.
+
+ :param text: the string to style with ansi codes
+ :param fg: foreground color
+ :param bg: background color
+ :param bold: enable or disable bold mode
+ :param dim: enable or disable dim mode
+ :param underline: enable or disable underline
+ :param overline: enable or disable overline
+ :param italic: enable or disable italic
+ :param blink: enable or disable blinking
+ :param reverse: enable or disable inverse rendering
+ :param strikethrough: enable or disable striking through text
+ :param reset: add a reset-all code at the end of the string
+ :return: styled text
+ """
+ if not isinstance(text, str):
+ text = str(text)
+
+ bits: list[str] = []
+ if fg:
+ try:
+ bits.append(f'\033[{_interpret_color(fg)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {fg!r}') from None
+ if bg:
+ try:
+ bits.append(f'\033[{_interpret_color(bg, 10)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {bg!r}') from None
+ if bold is not None:
+ bits.append(f'\033[{1 if bold else 22}m')
+ if dim is not None:
+ bits.append(f'\033[{2 if dim else 22}m')
+ if underline is not None:
+ bits.append(f'\033[{4 if underline else 24}m')
+ if overline is not None:
+ bits.append(f'\033[{53 if overline else 55}m')
+ if italic is not None:
+ bits.append(f'\033[{3 if italic else 23}m')
+ if blink is not None:
+ bits.append(f'\033[{5 if blink else 25}m')
+ if reverse is not None:
+ bits.append(f'\033[{7 if reverse else 27}m')
+ if strikethrough is not None:
+ bits.append(f'\033[{9 if strikethrough else 29}m')
+
+ bits.append(text)
+ if reset:
+ bits.append(ANSI_RESET_ALL)
+ return ''.join(bits)
+
+
+def unstyle(text: str) -> str:
+ """
+ Remove ANSI styling information from a string.
+
+ :param text: the text to remove style information from
+ :return: string with ANSI styling characters removed
+ """
+ return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text)
+
+
+def folduser(path: PathLike) -> str:
+ """
+ Replace the user's home directory with ``~`` in a path string.
+
+ :param path: path
+ :return: folded path string
+ """
+ return str(path).replace(str(Path.home()), '~')
+
+
+ERROR = style('ERROR', fg='bright_red', bold=True)
+INFO = style('INFO', fg='bright_blue', bold=True)
+WARNING = style('WARNING', fg='bright_yellow', bold=True)
+
+
+def printf(
+ text: str = '',
+ *,
+ fg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ indent: int = 0,
+ verbose: bool = False,
+) -> None:
+ """
+ Print styled text with optional indent and verbose gating.
+
+ :param text: text to print
+ :param fg: foreground color
+ :param bold: bold mode
+ :param dim: dim mode
+ :param indent: number of spaces to indent output by
+ :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr
+ """
+ if verbose and not VERBOSE:
+ return
+ if verbose:
+ fg = fg or 'bright_white'
+ dim = True if dim is None else dim
+ file = sys.stderr
+ else:
+ file = None
+
+ print(
+ style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim),
+ file=file,
+ )
+
+
+# ======================
+# Command-line arguments
+# ======================
+
+
+class Args(argparse.Namespace):
+ """
+ :class:`argparse.Namespace` annotated with args supported by this script.
+ """
+ filenames: Sequence[str] # positional arg(s)
+ verbose: bool # --verbose
+
+
+def parse_args(argv: Sequence[str] | None = None) -> Args:
+ """
+ Build argument parser and parse command-line args.
+
+ :param argv: command-line arguments (default: ``sys.argv[1:]``)
+ :return: :class:`Args` (typed :class:`argparse.Namespace` subclass)
+ """
+ global VERBOSE
+
+ parser = argparse.ArgumentParser(
+ description='Validate that JSON5 files have valid syntax (parsable)',
+ formatter_class=functools.partial(argparse.RawTextHelpFormatter, max_help_position=50),
+ add_help=False,
+ )
+
+ def add_positional_args() -> None:
+ """
+ Register the positional arguments group on the parser.
+ """
+ positional_args = parser.add_argument_group('Positional arguments')
+ positional_args.add_argument(
+ 'filenames',
+ nargs='*',
+ help='.json5 files to check',
+ )
+ add_positional_args()
+
+ def add_output_opts() -> None:
+ """
+ Register the output options group on the parser.
+ """
+ output_opts = parser.add_argument_group('Output options')
+ output_opts.add_argument(
+ '-v',
+ '--verbose',
+ action='store_true',
+ help='Output validation result for each analyzed file to console',
+ )
+ add_output_opts()
+
+ def add_other_opts() -> None:
+ """
+ Register the miscellaneous options group on the parser.
+ """
+ other_opts = parser.add_argument_group('Other options')
+ other_opts.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='Show this help message and exit',
+ )
+ add_other_opts()
+
+ args = parser.parse_args(argv, namespace=Args())
+ VERBOSE = args.verbose
+ return args
+
+
+# =================
+# Core script logic
+# =================
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """
+ Validate that JSON5 files have valid syntax (parsable).
+
+ :param argv: command-line arguments (default: ``sys.argv[1:]``)
+ :return: 0 on success, non-zero on failure
+ """
+ args = parse_args(argv)
+ if not args.filenames:
+ printf('No filenames to check', fg='yellow')
+ return 0
+
+ retval: int = 0
+ for filename in args.filenames:
+ path = Path(filename)
+ try:
+ raw = path.read_bytes()
+ except Exception as open_exc:
+ printf(f'{ERROR} {style(filename, fg="bright_white")}')
+ printf(f'Unable load file: {open_exc!s}', fg='bright_red', indent=8)
+ retval = 1
+ continue
+
+ try:
+ text = raw.decode('utf-8')
+ except UnicodeDecodeError as decode_exc:
+ printf(f'{ERROR} {style(filename, fg="bright_white")}')
+ printf(f'File is not valid UTF-8: {decode_exc!s}', fg='bright_red', indent=8)
+ retval = 1
+ continue
+
+ try:
+ pyjson5.loads(text)
+ except pyjson5.Json5DecoderException as json5_exc:
+ printf(f'{ERROR} {style(filename, fg="bright_white")}')
+ printf(f'{json5_exc.__class__.__name__}: {json5_exc.message}', fg='bright_red', indent=8)
+ retval = 1
+ continue
+ except Exception as json5_exc:
+ printf(f'{ERROR} {style(filename, fg="bright_white")}')
+ printf(f'Unable to decode JSON5: {json5_exc!s}', fg='bright_red', indent=8)
+ retval = 1
+ continue
+
+ else:
+ if args.verbose:
+ printf(f'{INFO} {style(filename, fg="bright_white")}')
+ printf(style('Valid JSON5', fg='green'), indent=8)
+
+ return retval
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/hooks/check-pkl.py b/scripts/hooks/check-pkl.py
new file mode 100755
index 0000000..6c57b69
--- /dev/null
+++ b/scripts/hooks/check-pkl.py
@@ -0,0 +1,434 @@
+#!/usr/bin/env python3
+"""
+Pre-commit hook to validate Pkl configuration files via ``pkl eval``.
+
+If ``pkl`` is not found on PATH, prints a warning and exits successfully (exit code 0) so that the hook does not
+block commits in environments where ``pkl`` is not installed. Otherwise, runs ``pkl eval --format yaml`` on all
+provided files to validate syntax and schema correctness.
+"""
+from __future__ import annotations
+
+import argparse
+import functools
+import re
+import shutil
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Literal
+from typing import Union
+from typing import cast
+from typing import get_args
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+
+# ==========================
+# Constants/defaults/globals
+# ==========================
+
+
+PKL_INSTALLATION_DOCS_URL: str = 'https://pkl-lang.org/main/current/pkl-cli/index.html#installation'
+
+VERBOSE: bool = False
+
+
+# ===============
+# Types/protocols
+# ===============
+
+
+PathLike = Union[str, Path]
+StyleColor = Union[int, tuple[int, int, int], str]
+
+
+# ==================
+# Formatting/logging
+# ==================
+
+
+def style(
+ text: Any,
+ *,
+ fg: StyleColor | None = None,
+ bg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ underline: bool | None = None,
+ overline: bool | None = None,
+ italic: bool | None = None,
+ blink: bool | None = None,
+ reverse: bool | None = None,
+ strikethrough: bool | None = None,
+ reset: bool = True,
+) -> str:
+ """
+ Style text with ANSI escape codes.
+
+ :param text: the string to style with ansi codes
+ :param fg: foreground color
+ :param bg: background color
+ :param bold: enable or disable bold mode
+ :param dim: enable or disable dim mode
+ :param underline: enable or disable underline
+ :param overline: enable or disable overline
+ :param italic: enable or disable italic
+ :param blink: enable or disable blinking
+ :param reverse: enable or disable inverse rendering
+ :param strikethrough: enable or disable striking through text
+ :param reset: add a reset-all code at the end of the string
+ :return: styled text
+ """
+ _ansi_colors: dict[str, int] = {
+ 'black': 30, 'bright_black': 90,
+ 'red': 31, 'bright_red': 91,
+ 'green': 32, 'bright_green': 92,
+ 'yellow': 33, 'bright_yellow': 93,
+ 'blue': 34, 'bright_blue': 94,
+ 'magenta': 35, 'bright_magenta': 95,
+ 'cyan': 36, 'bright_cyan': 96,
+ 'white': 37, 'bright_white': 97,
+ 'reset': 39,
+ }
+ _ansi_reset_all = '\033[0m'
+
+ def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
+ """
+ Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string.
+
+ :param _color: color as a named string, 256-color int, or ``(r, g, b)`` tuple
+ :param offset: offset added to the base code (0 for foreground, 10 for background)
+ :return: ANSI SGR parameter fragment (e.g. ``"32"`` or ``"38;2;255;0;0"``)
+ """
+ if isinstance(_color, int):
+ return f'{38 + offset};5;{_color:d}'
+ if isinstance(_color, (tuple, list)):
+ r, g, b = _color
+ return f'{38 + offset};2;{r:d};{g:d};{b:d}'
+ _color = cast('str', _color)
+ return str(_ansi_colors[_color] + offset)
+
+ if not isinstance(text, str):
+ text = str(text)
+
+ bits: list[str] = []
+ if fg:
+ try:
+ bits.append(f'\033[{_interpret_color(fg)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {fg!r}') from None
+ if bg:
+ try:
+ bits.append(f'\033[{_interpret_color(bg, 10)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {bg!r}') from None
+ if bold is not None:
+ bits.append(f'\033[{1 if bold else 22}m')
+ if dim is not None:
+ bits.append(f'\033[{2 if dim else 22}m')
+ if underline is not None:
+ bits.append(f'\033[{4 if underline else 24}m')
+ if overline is not None:
+ bits.append(f'\033[{53 if overline else 55}m')
+ if italic is not None:
+ bits.append(f'\033[{3 if italic else 23}m')
+ if blink is not None:
+ bits.append(f'\033[{5 if blink else 25}m')
+ if reverse is not None:
+ bits.append(f'\033[{7 if reverse else 27}m')
+ if strikethrough is not None:
+ bits.append(f'\033[{9 if strikethrough else 29}m')
+
+ bits.append(text)
+ if reset:
+ bits.append(_ansi_reset_all)
+ return ''.join(bits)
+
+
+def unstyle(text: str) -> str:
+ """
+ Remove ANSI styling information from a string.
+
+ :param text: the text to remove style information from
+ :return: string with ANSI styling characters removed
+ """
+ return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text)
+
+
+def folduser(path: PathLike) -> str:
+ """
+ Replace the user's home directory with ``~`` in a path string.
+
+ :param path: path
+ :return: folded path string
+ """
+ return str(path).replace(str(Path.home()), '~')
+
+
+ERROR = style('ERROR', fg='bright_red', bold=True)
+INFO = style('INFO', fg='bright_blue', bold=True)
+WARNING = style('WARNING', fg='bright_yellow', bold=True)
+
+
+def printf(
+ text: str = '',
+ *,
+ fg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ indent: int = 0,
+ verbose: bool = False,
+) -> None:
+ """
+ Print styled text with optional indent and verbose gating.
+
+ :param text: text to print
+ :param fg: foreground color
+ :param bold: bold mode
+ :param dim: dim mode
+ :param indent: number of spaces to indent output by
+ :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr
+ """
+ if verbose and not VERBOSE:
+ return
+ if verbose:
+ fg = fg or 'bright_white'
+ dim = True if dim is None else dim
+ file = sys.stderr
+ else:
+ file = None
+
+ print(
+ style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim),
+ file=file,
+ )
+
+
+# ======================
+# Command-line arguments
+# ======================
+
+
+ColorOption = Literal['never', 'auto', 'always']
+
+DEFAULT_COLOR_OPTION: ColorOption = 'always'
+
+
+class Args(argparse.Namespace):
+ """
+ :class:`argparse.Namespace` annotated with args supported by this script.
+ """
+ filenames: Sequence[str] # positional arg(s)
+ require_pkl: bool # --require-pkl
+ executable: str # --executable
+ verbose: bool # --verbose
+ dump_yaml: bool # --dump-yaml
+ color: ColorOption | None # --color
+ timeout: float | None # --timeout
+ no_cache: bool # --no-cache
+
+
+def parse_args(argv: Sequence[str] | None = None) -> Args:
+ """
+ Build argument parser and parse command-line args.
+
+ :param argv: command-line arguments (default: ``sys.argv[1:]``)
+ :return: :class:`Args` (typed :class:`argparse.Namespace` subclass)
+ """
+ global VERBOSE
+
+ parser = argparse.ArgumentParser(
+ description='Validate Pkl files by evaluating them with ``pkl eval``',
+ formatter_class=functools.partial(argparse.RawTextHelpFormatter, max_help_position=50),
+ add_help=False,
+ )
+
+ def add_positional_args() -> None:
+ """
+ Register the positional arguments group on the parser.
+ """
+ positional_args = parser.add_argument_group('Positional arguments')
+ positional_args.add_argument(
+ 'filenames',
+ nargs='*',
+ help='.pkl files to check',
+ )
+ add_positional_args()
+
+ def add_pkl_executable_opts() -> None:
+ """
+ Register the Pkl executable options group on the parser.
+ """
+ pkl_executable_opts = parser.add_argument_group('Pkl executable options')
+ pkl_executable_opts.add_argument(
+ '--require-pkl',
+ action='store_true',
+ default=False,
+ help=(
+ 'Exit with error (nonzero status) if ``pkl`` executable is not found.\n'
+ 'By default, simple warning outputted and script exits with status code 0'
+ ),
+ )
+ pkl_executable_opts.add_argument(
+ '--executable',
+ metavar='PATH',
+ type=str,
+ default='pkl',
+ help='Filepath to ``pkl`` executable to use. Default: "pkl" (resolve from PATH)',
+ )
+ add_pkl_executable_opts()
+
+ def add_output_opts() -> None:
+ """
+ Register the output options group on the parser.
+ """
+ output_opts = parser.add_argument_group('Output options')
+ output_opts.add_argument(
+ '-v',
+ '--verbose',
+ action='store_true',
+ default=False,
+ help='Output validation result for each analyzed file to console',
+ )
+ output_opts.add_argument(
+ '--dump-yaml',
+ action='store_true',
+ default=False,
+ help='Dump YAML generated from module evaluation to stdout',
+ )
+ output_opts.add_argument(
+ '--color',
+ choices=get_args(ColorOption),
+ default=DEFAULT_COLOR_OPTION,
+ help=(
+ f'Whether to format messages in ANSI color\n'
+ f'{style("Default:", fg="bright_white", dim=True)} {style(DEFAULT_COLOR_OPTION, fg="green", dim=True)}'
+ ),
+ )
+ add_output_opts()
+
+ def add_execution_opts() -> None:
+ """
+ Register the execution options group on the parser.
+ """
+ execution_opts = parser.add_argument_group('Execution options')
+ execution_opts.add_argument(
+ '-t',
+ '--timeout',
+ metavar='SECONDS',
+ type=float,
+ default=None,
+ help='Duration (in seconds) after which evaluation of source module will be timed out',
+ )
+ add_execution_opts()
+
+ def add_package_opts() -> None:
+ """
+ Register the package options group on the parser.
+ """
+ package_opts = parser.add_argument_group('Package options')
+ package_opts.add_argument(
+ '--no-cache',
+ action='store_true',
+ default=False,
+ help='Disable caching of packages',
+ )
+ add_package_opts()
+
+ def add_other_opts() -> None:
+ """
+ Register the miscellaneous options group on the parser.
+ """
+ other_opts = parser.add_argument_group('Other options')
+ other_opts.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='Show this help message and exit',
+ )
+ add_other_opts()
+
+ args = parser.parse_args(argv, namespace=Args())
+ VERBOSE = args.verbose
+ return args
+
+
+# =================
+# Core script logic
+# =================
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """
+ Validate Pkl files by evaluating them with ``pkl eval``.
+
+ :param argv: command-line arguments (default: ``sys.argv[1:]``)
+ :return: 0 on success, non-zero on failure
+ """
+ args = parse_args(argv)
+ if not args.filenames:
+ printf('No filenames to check', fg='yellow')
+ return 0
+
+ if not shutil.which(args.executable):
+ if args.require_pkl:
+ err = f'{args.executable} not found'
+ printf(f'{ERROR} {style(err, fg="bright_red")}')
+ printf(f'Install pkl: {PKL_INSTALLATION_DOCS_URL}', indent=8)
+ return 1
+ printf(
+ f'{WARNING} '
+ f'{style("Executable not found", fg="bright_white")}: '
+ f'{style(args.executable, fg="bright_magenta")}'
+ )
+ printf('Skipping Pkl syntax validation', indent=8)
+ printf(style(PKL_INSTALLATION_DOCS_URL, dim=True), indent=8)
+ return 0
+
+ base_cmd: list[str] = [args.executable, 'eval', '--format', 'yaml']
+ if args.color is not None:
+ base_cmd.extend(['--color', args.color])
+ if args.timeout is not None:
+ base_cmd.extend(['--timeout', str(args.timeout)])
+ if args.no_cache:
+ base_cmd.append('--no-cache')
+
+ retval: int = 0
+ for filename in args.filenames:
+ try:
+ result = subprocess.run([*base_cmd, filename], check=True, capture_output=True, text=True)
+
+ except subprocess.CalledProcessError as cmd_exc:
+ printf(f'{ERROR} {style(filename, fg="bright_white")}')
+ if cmd_exc.stderr:
+ stderr_lines = cmd_exc.stderr.splitlines()
+ if unstyle(stderr_lines[0].strip()) == '–– Pkl Error ––': # noqa: RUF001 (pkl emits literal en-dashes)
+ stderr_lines = stderr_lines[1:]
+ printf('\n'.join(stderr_lines), indent=8)
+ else:
+ printf(str(cmd_exc), indent=8)
+ retval = 1
+
+ except Exception as e:
+ printf(f'{ERROR} {style(filename, fg="bright_white")}')
+ printf(f'{e!s}', indent=8)
+ retval = 1
+
+ else:
+ if args.verbose:
+ printf(f'{INFO} {style(filename, fg="bright_white")}')
+ printf(style('Valid Pkl', fg='green'), indent=8)
+ if args.dump_yaml:
+ printf(style('Compiled YAML:', fg='bright_white', bold=True, dim=True), indent=8)
+ printf(style(result.stdout, dim=True), indent=10)
+
+ return retval
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/hooks/format-pkl.py b/scripts/hooks/format-pkl.py
new file mode 100755
index 0000000..77a77e4
--- /dev/null
+++ b/scripts/hooks/format-pkl.py
@@ -0,0 +1,408 @@
+#!/usr/bin/env python3
+"""
+Pre-commit hook to auto-format Pkl configuration files via ``pkl format``.
+
+If ``pkl`` is not found on PATH, prints a warning and exits successfully (exit code 0) so that the hook does not
+block commits in environments where ``pkl`` is not installed. Otherwise, runs ``pkl format`` on all provided files
+to enforce consistent formatting.
+"""
+from __future__ import annotations
+
+import argparse
+import functools
+import re
+import shutil
+import subprocess
+import sys
+import textwrap
+from pathlib import Path
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Literal
+from typing import Union
+from typing import cast
+from typing import get_args
+
+if TYPE_CHECKING:
+ from collections.abc import Sequence
+
+
+# ==========================
+# Constants/defaults/globals
+# ==========================
+
+
+PKL_INSTALLATION_DOCS_URL: str = 'https://pkl-lang.org/main/current/pkl-cli/index.html#installation'
+
+VERBOSE: bool = False
+
+
+# ===============
+# Types/protocols
+# ===============
+
+
+PathLike = Union[str, Path]
+StyleColor = Union[int, tuple[int, int, int], str]
+
+
+# ==================
+# Formatting/logging
+# ==================
+
+
+def style(
+ text: Any,
+ *,
+ fg: StyleColor | None = None,
+ bg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ underline: bool | None = None,
+ overline: bool | None = None,
+ italic: bool | None = None,
+ blink: bool | None = None,
+ reverse: bool | None = None,
+ strikethrough: bool | None = None,
+ reset: bool = True,
+) -> str:
+ """
+ Style text with ANSI escape codes.
+
+ :param text: the string to style with ansi codes
+ :param fg: foreground color
+ :param bg: background color
+ :param bold: enable or disable bold mode
+ :param dim: enable or disable dim mode
+ :param underline: enable or disable underline
+ :param overline: enable or disable overline
+ :param italic: enable or disable italic
+ :param blink: enable or disable blinking
+ :param reverse: enable or disable inverse rendering
+ :param strikethrough: enable or disable striking through text
+ :param reset: add a reset-all code at the end of the string
+ :return: styled text
+ """
+ _ansi_colors: dict[str, int] = {
+ 'black': 30, 'bright_black': 90,
+ 'red': 31, 'bright_red': 91,
+ 'green': 32, 'bright_green': 92,
+ 'yellow': 33, 'bright_yellow': 93,
+ 'blue': 34, 'bright_blue': 94,
+ 'magenta': 35, 'bright_magenta': 95,
+ 'cyan': 36, 'bright_cyan': 96,
+ 'white': 37, 'bright_white': 97,
+ 'reset': 39,
+ }
+ _ansi_reset_all = '\033[0m'
+
+ def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
+ """
+ Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string.
+
+ :param _color: color as a named string, 256-color int, or ``(r, g, b)`` tuple
+ :param offset: offset added to the base code (0 for foreground, 10 for background)
+ :return: ANSI SGR parameter fragment (e.g. ``"32"`` or ``"38;2;255;0;0"``)
+ """
+ if isinstance(_color, int):
+ return f'{38 + offset};5;{_color:d}'
+ if isinstance(_color, (tuple, list)):
+ r, g, b = _color
+ return f'{38 + offset};2;{r:d};{g:d};{b:d}'
+ _color = cast('str', _color)
+ return str(_ansi_colors[_color] + offset)
+
+ if not isinstance(text, str):
+ text = str(text)
+
+ bits: list[str] = []
+ if fg:
+ try:
+ bits.append(f'\033[{_interpret_color(fg)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {fg!r}') from None
+ if bg:
+ try:
+ bits.append(f'\033[{_interpret_color(bg, 10)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {bg!r}') from None
+ if bold is not None:
+ bits.append(f'\033[{1 if bold else 22}m')
+ if dim is not None:
+ bits.append(f'\033[{2 if dim else 22}m')
+ if underline is not None:
+ bits.append(f'\033[{4 if underline else 24}m')
+ if overline is not None:
+ bits.append(f'\033[{53 if overline else 55}m')
+ if italic is not None:
+ bits.append(f'\033[{3 if italic else 23}m')
+ if blink is not None:
+ bits.append(f'\033[{5 if blink else 25}m')
+ if reverse is not None:
+ bits.append(f'\033[{7 if reverse else 27}m')
+ if strikethrough is not None:
+ bits.append(f'\033[{9 if strikethrough else 29}m')
+
+ bits.append(text)
+ if reset:
+ bits.append(_ansi_reset_all)
+ return ''.join(bits)
+
+
+def unstyle(text: str) -> str:
+ """
+ Remove ANSI styling information from a string.
+
+ :param text: the text to remove style information from
+ :return: string with ANSI styling characters removed
+ """
+ return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text)
+
+
+def folduser(path: PathLike) -> str:
+ """
+ Replace the user's home directory with ``~`` in a path string.
+
+ :param path: path
+ :return: folded path string
+ """
+ return str(path).replace(str(Path.home()), '~')
+
+
+ERROR = style('ERROR', fg='bright_red', bold=True)
+WARNING = style('WARNING', fg='bright_yellow', bold=True)
+MODIFIED = style('MODIFIED', fg='bright_yellow')
+UNMODIFIED = style('UNMODIFIED', fg='bright_blue')
+
+
+def printf(
+ text: str = '',
+ *,
+ fg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ indent: int = 0,
+ verbose: bool = False,
+) -> None:
+ """
+ Print styled text with optional indent and verbose gating.
+
+ :param text: text to print
+ :param fg: foreground color
+ :param bold: bold mode
+ :param dim: dim mode
+ :param indent: number of spaces to indent output by
+ :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr
+ """
+ if verbose and not VERBOSE:
+ return
+ if verbose:
+ fg = fg or 'bright_white'
+ dim = True if dim is None else dim
+ file = sys.stderr
+ else:
+ file = None
+
+ print(
+ style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim),
+ file=file,
+ )
+
+
+# ======================
+# Command-line arguments
+# ======================
+
+
+GrammarVersion = Literal[1, 2]
+
+DEFAULT_GRAMMAR_VERSION: GrammarVersion = 2
+
+
+class Args(argparse.Namespace):
+ """
+ :class:`argparse.Namespace` annotated with args supported by this script.
+ """
+ filenames: Sequence[str] # positional arg(s)
+ require_pkl: bool # --require-pkl
+ executable: str # --executable
+ grammar_version: GrammarVersion # --grammar-version
+ verbose: bool # --verbose
+
+
+def parse_args(argv: Sequence[str] | None = None) -> Args:
+ """
+ Build argument parser and parse command-line args.
+
+ :param argv: command-line arguments (default: ``sys.argv[1:]``)
+ :return: :class:`Args` (typed :class:`argparse.Namespace` subclass)
+ """
+ global VERBOSE
+
+ parser = argparse.ArgumentParser(
+ description='Format Pkl files using ``pkl format``',
+ formatter_class=functools.partial(argparse.RawTextHelpFormatter, max_help_position=50),
+ add_help=False,
+ )
+
+ def add_positional_args() -> None:
+ """
+ Register the positional arguments group on the parser.
+ """
+ positional_args = parser.add_argument_group('Positional arguments')
+ positional_args.add_argument(
+ 'filenames',
+ nargs='*',
+ help='.pkl files to format',
+ )
+ add_positional_args()
+
+ def add_pkl_executable_opts() -> None:
+ """
+ Register the Pkl executable options group on the parser.
+ """
+ pkl_executable_opts = parser.add_argument_group('Pkl executable options')
+ pkl_executable_opts.add_argument(
+ '--require-pkl',
+ action='store_true',
+ default=False,
+ help=(
+ 'Exit with error (nonzero status) if ``pkl`` executable is not found.\n'
+ 'By default, simple warning outputted and script exits with status code 0'
+ ),
+ )
+ pkl_executable_opts.add_argument(
+ '--executable',
+ metavar='PATH',
+ type=str,
+ default='pkl',
+ help='Filepath to ``pkl`` executable to use. Default: "pkl" (resolve from PATH)',
+ )
+ add_pkl_executable_opts()
+
+ def add_grammar_options() -> None:
+ """
+ Register the grammar version options group on the parser.
+ """
+ grammar_opts = parser.add_argument_group('Grammar options')
+ grammar_opts.add_argument(
+ '--grammar-version',
+ type=int,
+ choices=get_args(GrammarVersion),
+ default=DEFAULT_GRAMMAR_VERSION,
+ help=(
+ f'The grammar compatibility version to use\n'
+ f' 1: 0.25 - 0.29\n'
+ f' 2: 0.30+ {style("(default)", dim=True, fg="bright_white")}'
+ ),
+ )
+ add_grammar_options()
+
+ def add_output_opts() -> None:
+ """
+ Register the output options group on the parser.
+ """
+ output_opts = parser.add_argument_group('Output options')
+ output_opts.add_argument(
+ '-v',
+ '--verbose',
+ action='store_true',
+ default=False,
+ help='Output formatting result for each processed file to console',
+ )
+ add_output_opts()
+
+ def add_other_opts() -> None:
+ """
+ Register the miscellaneous options group on the parser.
+ """
+ other_opts = parser.add_argument_group('Other options')
+ other_opts.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='Show this help message and exit',
+ )
+ add_other_opts()
+
+ args = parser.parse_args(argv, namespace=Args())
+ VERBOSE = args.verbose
+ return args
+
+
+# =================
+# Core script logic
+# =================
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """
+ Format Pkl files using ``pkl format``.
+
+ :param argv: command-line arguments (default: ``sys.argv[1:]``)
+ :return: 0 on success, non-zero on failure
+ """
+ args = parse_args(argv)
+ if not args.filenames:
+ print(style('No filenames to format', fg='yellow'))
+ return 0
+
+ if not shutil.which(args.executable):
+ if args.require_pkl:
+ err = f'{args.executable} not found'
+ printf(f'{ERROR} {style(err, fg="bright_red")}')
+ printf(f'Install pkl: {PKL_INSTALLATION_DOCS_URL}', indent=12)
+ return 1
+ printf(
+ f'{WARNING} '
+ f'{style("Executable not found", fg="bright_white")}: '
+ f'{style(args.executable, fg="bright_magenta")}'
+ )
+ printf('Skipping Pkl formatting', indent=12)
+ printf(style(PKL_INSTALLATION_DOCS_URL, dim=True), indent=12)
+ return 0
+
+ base_cmd: list[str] = [args.executable, 'format', '--diff-name-only']
+ if args.grammar_version:
+ base_cmd.extend(['--grammar-version', str(args.grammar_version)])
+
+ retval: int = 0
+ for filename in args.filenames:
+ try:
+ try:
+ subprocess.run([*base_cmd, filename], check=True, capture_output=True, text=True)
+ except subprocess.CalledProcessError as cmd_exc:
+ if cmd_exc.returncode == 11:
+ subprocess.run([*base_cmd, filename, '--write'], check=True, capture_output=True, text=True)
+ print(f'{MODIFIED} {style(filename, fg="bright_white")}')
+ continue
+ raise
+
+ except subprocess.CalledProcessError as cmd_exc:
+ print(f'{ERROR} {style(filename, fg="bright_white")}')
+ if cmd_exc.stderr:
+ stderr_lines = [
+ line for line in cmd_exc.stderr.splitlines()
+ if line.strip() and (line.strip() != 'An error occurred during formatting.')
+ ]
+ printf('\n'.join(stderr_lines), indent=12)
+ else:
+ printf(str(cmd_exc), indent=12)
+
+ retval = max(retval, cmd_exc.returncode)
+
+ except Exception as e:
+ printf(f'{ERROR} {style(filename, fg="bright_white")}')
+ printf(f'{e!s}', indent=12)
+ return 1
+
+ else:
+ if args.verbose:
+ printf(f'{UNMODIFIED} {style(filename)}')
+
+ return retval
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/scripts/hooks/sort-json-array.py b/scripts/hooks/sort-json-array.py
new file mode 100755
index 0000000..d388293
--- /dev/null
+++ b/scripts/hooks/sort-json-array.py
@@ -0,0 +1,369 @@
+#!/usr/bin/env python3
+"""
+Pre-commit hook to sort a top-level JSON array of objects by a chosen key.
+"""
+# /// script
+# requires-python = ">= 3.9"
+# dependencies = []
+# ///
+from __future__ import annotations
+
+import argparse
+import functools
+import json
+import re
+import sys
+import textwrap
+from collections.abc import Sequence
+from pathlib import Path
+from typing import Any
+from typing import Union
+from typing import cast
+
+# ==========================
+# Constants/defaults/globals
+# ==========================
+
+
+VERBOSE: bool = False
+DEFAULT_KEY: str = 'command'
+DEFAULT_INDENT: int = 4
+
+
+# ===============
+# Types/protocols
+# ===============
+
+
+PathLike = Union[str, Path]
+StyleColor = Union[int, tuple[int, int, int], str]
+
+
+# ==================
+# Formatting/logging
+# ==================
+
+
+ANSI_COLOR: dict[str, int] = {
+ 'black': 30, 'bright_black': 90,
+ 'red': 31, 'bright_red': 91,
+ 'green': 32, 'bright_green': 92,
+ 'yellow': 33, 'bright_yellow': 93,
+ 'blue': 34, 'bright_blue': 94,
+ 'magenta': 35, 'bright_magenta': 95,
+ 'cyan': 36, 'bright_cyan': 96,
+ 'white': 37, 'bright_white': 97,
+ 'reset': 39,
+}
+ANSI_RESET_ALL = '\033[0m'
+
+
+def _interpret_color(_color: StyleColor, offset: int = 0) -> str:
+ """
+ Resolve a color name, integer, or RGB tuple to an ANSI SGR parameter string.
+
+ :param _color: color as a named string, 256-color int, or ``(r, g, b)`` tuple
+ :param offset: offset added to the base code (0 for foreground, 10 for background)
+ :return: ANSI SGR parameter fragment (e.g. ``"32"`` or ``"38;2;255;0;0"``)
+ """
+ if isinstance(_color, int):
+ return f'{38 + offset};5;{_color:d}'
+ if isinstance(_color, (tuple, list)):
+ r, g, b = _color
+ return f'{38 + offset};2;{r:d};{g:d};{b:d}'
+ _color = cast('str', _color)
+ return str(ANSI_COLOR[_color] + offset)
+
+
+def style(
+ text: Any = '',
+ *,
+ fg: StyleColor | None = None,
+ bg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ underline: bool | None = None,
+ overline: bool | None = None,
+ italic: bool | None = None,
+ blink: bool | None = None,
+ reverse: bool | None = None,
+ strikethrough: bool | None = None,
+ reset: bool = True,
+) -> str:
+ """
+ Style text with ANSI escape codes.
+
+ :param text: the string to style with ansi codes
+ :param fg: foreground color
+ :param bg: background color
+ :param bold: enable or disable bold mode
+ :param dim: enable or disable dim mode
+ :param underline: enable or disable underline
+ :param overline: enable or disable overline
+ :param italic: enable or disable italic
+ :param blink: enable or disable blinking
+ :param reverse: enable or disable inverse rendering
+ :param strikethrough: enable or disable striking through text
+ :param reset: add a reset-all code at the end of the string
+ :return: styled text
+ """
+ if not isinstance(text, str):
+ text = str(text)
+
+ bits: list[str] = []
+ if fg:
+ try:
+ bits.append(f'\033[{_interpret_color(fg)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {fg!r}') from None
+ if bg:
+ try:
+ bits.append(f'\033[{_interpret_color(bg, 10)}m')
+ except KeyError:
+ raise TypeError(f'Unknown color {bg!r}') from None
+ if bold is not None:
+ bits.append(f'\033[{1 if bold else 22}m')
+ if dim is not None:
+ bits.append(f'\033[{2 if dim else 22}m')
+ if underline is not None:
+ bits.append(f'\033[{4 if underline else 24}m')
+ if overline is not None:
+ bits.append(f'\033[{53 if overline else 55}m')
+ if italic is not None:
+ bits.append(f'\033[{3 if italic else 23}m')
+ if blink is not None:
+ bits.append(f'\033[{5 if blink else 25}m')
+ if reverse is not None:
+ bits.append(f'\033[{7 if reverse else 27}m')
+ if strikethrough is not None:
+ bits.append(f'\033[{9 if strikethrough else 29}m')
+
+ bits.append(text)
+ if reset:
+ bits.append(ANSI_RESET_ALL)
+ return ''.join(bits)
+
+
+def unstyle(text: str) -> str:
+ """
+ Remove ANSI styling information from a string.
+
+ :param text: the text to remove style information from
+ :return: string with ANSI styling characters removed
+ """
+ return re.sub(r'\033\[[;?0-9]*[a-zA-Z]', '', text)
+
+
+def folduser(path: PathLike) -> str:
+ """
+ Replace the user's home directory with ``~`` in a path string.
+
+ :param path: path
+ :return: folded path string
+ """
+ return str(path).replace(str(Path.home()), '~')
+
+
+ERROR = style('ERROR', fg='bright_red', bold=True)
+INFO = style('INFO', fg='bright_blue', bold=True)
+WARNING = style('WARNING', fg='bright_yellow', bold=True)
+
+
+def printf(
+ text: str = '',
+ *,
+ fg: StyleColor | None = None,
+ bold: bool | None = None,
+ dim: bool | None = None,
+ indent: int = 0,
+ verbose: bool = False,
+) -> None:
+ """
+ Print styled text with optional indent and verbose gating.
+
+ :param text: text to print
+ :param fg: foreground color
+ :param bold: bold mode
+ :param dim: dim mode
+ :param indent: number of spaces to indent output by
+ :param verbose: only print when global ``VERBOSE`` is True. Output is dim and sent to stderr
+ """
+ if verbose and not VERBOSE:
+ return
+ if verbose:
+ fg = fg or 'bright_white'
+ dim = True if dim is None else dim
+ file = sys.stderr
+ else:
+ file = None
+
+ print(
+ style(textwrap.indent(text, ' ' * indent), fg=fg, bold=bold, dim=dim),
+ file=file,
+ )
+
+
+# ======================
+# Command-line arguments
+# ======================
+
+
+class Args(argparse.Namespace):
+ """
+ :class:`argparse.Namespace` annotated with args supported by this script.
+ """
+ filenames: Sequence[str] # positional arg(s)
+ key: str # --key
+ indent: int # --indent
+ verbose: bool # --verbose
+
+
+def parse_args(argv: Sequence[str] | None = None) -> Args:
+ """
+ Build argument parser and parse command-line args.
+
+ :param argv: command-line arguments (default: ``sys.argv[1:]``)
+ :return: :class:`Args` (typed :class:`argparse.Namespace` subclass)
+ """
+ global VERBOSE
+
+ parser = argparse.ArgumentParser(
+ description='Sort a top-level JSON array of objects by a chosen key',
+ formatter_class=functools.partial(argparse.RawTextHelpFormatter, max_help_position=50),
+ add_help=False,
+ )
+
+ def add_positional_args() -> None:
+ """
+ Register the positional arguments group on the parser.
+ """
+ positional_args = parser.add_argument_group('Positional arguments')
+ positional_args.add_argument(
+ 'filenames',
+ nargs='*',
+ help='JSON files (top-level array of objects) to sort',
+ )
+ add_positional_args()
+
+ def add_sort_opts() -> None:
+ """
+ Register the sorting options group on the parser.
+ """
+ sort_opts = parser.add_argument_group('Sorting options')
+ sort_opts.add_argument(
+ '-k',
+ '--key',
+ default=DEFAULT_KEY,
+ help=f'Object key whose value each array entry is sorted by (default: {DEFAULT_KEY!r})',
+ )
+ sort_opts.add_argument(
+ '--indent',
+ type=int,
+ default=DEFAULT_INDENT,
+ help=f'Number of spaces to indent rewritten JSON by (default: {DEFAULT_INDENT})',
+ )
+ add_sort_opts()
+
+ def add_output_opts() -> None:
+ """
+ Register the output options group on the parser.
+ """
+ output_opts = parser.add_argument_group('Output options')
+ output_opts.add_argument(
+ '-v',
+ '--verbose',
+ action='store_true',
+ help='Output sort result for each analyzed file to console',
+ )
+ add_output_opts()
+
+ def add_other_opts() -> None:
+ """
+ Register the miscellaneous options group on the parser.
+ """
+ other_opts = parser.add_argument_group('Other options')
+ other_opts.add_argument(
+ '-h',
+ '--help',
+ action='help',
+ default=argparse.SUPPRESS,
+ help='Show this help message and exit',
+ )
+ add_other_opts()
+
+ args = parser.parse_args(argv, namespace=Args())
+ VERBOSE = args.verbose
+ return args
+
+
+# =================
+# Core script logic
+# =================
+
+
+def sort_file(path: Path, *, key: str, indent: int) -> int:
+ """
+ Sort a single JSON file's top-level array of objects by ``key`` in place.
+
+ The file is rewritten only when the sorted, re-serialized content differs from the original. Output matches
+ the ``pretty-format-json`` hook (sorted object keys, trailing newline) so the two never produce competing rewrites.
+
+ :param path: path to the JSON file to sort
+ :param key: object key whose value each array entry is sorted by
+ :param indent: number of spaces to indent the rewritten JSON by
+ :return: 0 if already sorted, 1 if the file was rewritten or could not be processed
+ """
+ try:
+ original = path.read_text(encoding='utf-8')
+ except Exception as open_exc:
+ printf(f'{ERROR} {style(path, fg="bright_white")}')
+ printf(f'Unable to load file: {open_exc!s}', fg='bright_red', indent=8)
+ return 1
+
+ try:
+ data = json.loads(original)
+ except json.JSONDecodeError as json_exc:
+ printf(f'{ERROR} {style(path, fg="bright_white")}')
+ printf(f'Unable to decode JSON: {json_exc!s}', fg='bright_red', indent=8)
+ return 1
+
+ if (not isinstance(data, list)) or not all(isinstance(entry, dict) for entry in data):
+ printf(f'{ERROR} {style(path, fg="bright_white")}')
+ printf('Expected a top-level array of objects', fg='bright_red', indent=8)
+ return 1
+
+ ordered = sorted(data, key=lambda entry: entry.get(key, ''))
+ sorted_text = json.dumps(ordered, indent=indent, ensure_ascii=True, sort_keys=True, separators=(',', ': ')) + '\n'
+
+ if sorted_text == original:
+ if VERBOSE:
+ printf(f'{INFO} {style(path, fg="bright_white")}')
+ printf(style(f'Already sorted by {key!r}', fg='green'), indent=8)
+ return 0
+
+ path.write_text(sorted_text, encoding='utf-8')
+ printf(f'{INFO} {style(path, fg="bright_white")}')
+ printf(style(f'Sorted array by {key!r}', fg='yellow'), indent=8)
+ return 1
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+ """
+ Sort top-level JSON arrays of objects by a chosen key.
+
+ :param argv: command-line arguments (default: ``sys.argv[1:]``)
+ :return: 0 if every file was already sorted, non-zero if any was rewritten or failed
+ """
+ args = parse_args(argv)
+ if not args.filenames:
+ printf('No filenames to sort', fg='yellow')
+ return 0
+
+ retval: int = 0
+ for filename in args.filenames:
+ retval |= sort_file(Path(filename), key=args.key, indent=args.indent)
+ return retval
+
+
+if __name__ == '__main__':
+ sys.exit(main())