hooks.py
python
sha256:4802c0281407b5de70ce1b34bbe1d1cd055533cbd4b8be35861a6a3eb545c1dc
feat(#192): Phase 1 — .musehooks.toml parser + `muse hooks list`
Sonnet 5
patch
5 days ago
| 1 | """Muse hooks — ``.musehooks.toml`` parser and validator. |
| 2 | |
| 3 | ``.musehooks.toml`` lives in the repository root (next to ``.museignore``, |
| 4 | ``.museattributes``, and ``.museagent.md``) and declares commands to run at |
| 5 | specific commit-lifecycle points. Unlike ``.muse/hooks/`` (which would live |
| 6 | inside the object-store directory and therefore could never be committed, |
| 7 | pushed, or survive a clone — ``.muse/`` is unconditionally excluded from |
| 8 | every snapshot, see :mod:`muse.core.paths`), ``.musehooks.toml`` is an |
| 9 | ordinary tracked file: it is shared automatically via clone/push/pull. |
| 10 | |
| 11 | Hooks declared here are **never executed automatically**. A human or agent |
| 12 | must explicitly run ``muse hooks install`` in a given clone to activate them |
| 13 | locally (musehub#192 Phase 2) — this mirrors git's own precedent of never |
| 14 | auto-running code from a freshly cloned repository, and keeps a malicious or |
| 15 | compromised repo from gaining code execution merely by being cloned. |
| 16 | |
| 17 | MVP scope (musehub#192): exactly one hook point, ``pre-commit``, and a |
| 18 | declarative list of shell commands — no arbitrary hook scripts, no hashbang |
| 19 | files. Each command's exit code determines pass/fail when ``muse commit`` |
| 20 | runs installed hooks (Phase 3). |
| 21 | |
| 22 | Format |
| 23 | ------ |
| 24 | |
| 25 | .. code-block:: toml |
| 26 | |
| 27 | # .musehooks.toml |
| 28 | [pre-commit] |
| 29 | commands = [ |
| 30 | "muse agent-config status --fail-if-out-of-sync", |
| 31 | ] |
| 32 | |
| 33 | Public API |
| 34 | ---------- |
| 35 | |
| 36 | - :class:`HooksFile` — parsed representation of ``.musehooks.toml``. |
| 37 | - :data:`VALID_HOOK_POINTS` — the set of recognised ``[<hook-point>]`` section names. |
| 38 | - :func:`load_hooks` — read ``.musehooks.toml`` from a repo root. |
| 39 | """ |
| 40 | |
| 41 | import tomllib |
| 42 | from dataclasses import dataclass, field |
| 43 | |
| 44 | _FILENAME = ".musehooks.toml" |
| 45 | |
| 46 | # MVP is deliberately scoped to pre-commit only — see musehub#192 "Out of Scope". |
| 47 | VALID_HOOK_POINTS: frozenset[str] = frozenset({"pre-commit"}) |
| 48 | |
| 49 | # 1 MiB cap — prevents OOM from a crafted or corrupted .musehooks.toml, |
| 50 | # matching the precedent set by .museattributes (muse/core/attributes.py). |
| 51 | _MAX_HOOKS_BYTES: int = 1 * 1024 * 1024 |
| 52 | |
| 53 | |
| 54 | @dataclass(frozen=True) |
| 55 | class HooksFile: |
| 56 | """Parsed representation of ``.musehooks.toml``. |
| 57 | |
| 58 | Attributes: |
| 59 | hooks: Mapping of hook point name (e.g. ``"pre-commit"``) to its |
| 60 | ordered list of shell commands. A hook point with no |
| 61 | ``commands`` key, or an empty ``commands = []``, maps to an |
| 62 | empty list — both are valid, meaning "hook point declared but |
| 63 | nothing to run yet." |
| 64 | """ |
| 65 | |
| 66 | hooks: dict[str, list[str]] = field(default_factory=dict) |
| 67 | |
| 68 | |
| 69 | def load_hooks(root) -> HooksFile: # noqa: ANN001 - accepts pathlib.Path |
| 70 | """Parse ``.musehooks.toml`` from *root* and return a :class:`HooksFile`. |
| 71 | |
| 72 | A missing file is not an error — it means no hooks are defined, and |
| 73 | returns an empty :class:`HooksFile`. This matches |
| 74 | :func:`muse.core.attributes.load_attributes`'s precedent: absence is a |
| 75 | valid, common state, not a failure. |
| 76 | |
| 77 | Args: |
| 78 | root: Repository root directory (``pathlib.Path``). |
| 79 | |
| 80 | Returns: |
| 81 | The parsed :class:`HooksFile`. Empty (``hooks={}``) when the file is |
| 82 | absent, empty, or contains only comments. |
| 83 | |
| 84 | Raises: |
| 85 | ValueError: If the file exceeds the 1 MiB size cap, contains invalid |
| 86 | TOML syntax, declares an unknown hook point, or a hook point's |
| 87 | ``commands`` value is not a list of strings. |
| 88 | """ |
| 89 | hooks_file = root / _FILENAME |
| 90 | if not hooks_file.exists(): |
| 91 | return HooksFile(hooks={}) |
| 92 | |
| 93 | raw_bytes = hooks_file.read_bytes() |
| 94 | if len(raw_bytes) > _MAX_HOOKS_BYTES: |
| 95 | raise ValueError( |
| 96 | f"{_FILENAME}: file too large ({len(raw_bytes):,} bytes > " |
| 97 | f"{_MAX_HOOKS_BYTES:,} byte limit)" |
| 98 | ) |
| 99 | |
| 100 | try: |
| 101 | raw = tomllib.loads(raw_bytes.decode("utf-8")) |
| 102 | except tomllib.TOMLDecodeError as exc: |
| 103 | raise ValueError(f"{_FILENAME}: TOML parse error — {exc}") from exc |
| 104 | |
| 105 | hooks: dict[str, list[str]] = {} |
| 106 | for section_name, section_value in raw.items(): |
| 107 | if section_name not in VALID_HOOK_POINTS: |
| 108 | raise ValueError( |
| 109 | f"{_FILENAME}: unknown hook point [{section_name}]. " |
| 110 | f"Valid hook points: {sorted(VALID_HOOK_POINTS)}" |
| 111 | ) |
| 112 | if not isinstance(section_value, dict): |
| 113 | raise ValueError( |
| 114 | f"{_FILENAME}: [{section_name}] must be a table" |
| 115 | ) |
| 116 | |
| 117 | commands_raw = section_value.get("commands", []) |
| 118 | if not isinstance(commands_raw, list): |
| 119 | raise ValueError( |
| 120 | f"{_FILENAME}: [{section_name}].commands must be a list of strings" |
| 121 | ) |
| 122 | commands: list[str] = [] |
| 123 | for idx, cmd in enumerate(commands_raw): |
| 124 | if not isinstance(cmd, str): |
| 125 | raise ValueError( |
| 126 | f"{_FILENAME}: [{section_name}].commands[{idx}] must be " |
| 127 | f"a string, got {type(cmd).__name__}" |
| 128 | ) |
| 129 | commands.append(cmd) |
| 130 | hooks[section_name] = commands |
| 131 | |
| 132 | return HooksFile(hooks=hooks) |
File History
1 commit
sha256:4802c0281407b5de70ce1b34bbe1d1cd055533cbd4b8be35861a6a3eb545c1dc
feat(#192): Phase 1 — .musehooks.toml parser + `muse hooks list`
Sonnet 5
patch
5 days ago