"""Bridge hooks — ``.muse/bridge-hooks.toml``. Owns the hook dataclasses and the load/run helpers that execute pre- and post-bridge shell commands. """ from __future__ import annotations import pathlib import subprocess import sys from dataclasses import dataclass, field from muse.core.errors import ExitCode _Env = dict[str, str] @dataclass(frozen=True, eq=True) class BridgeHook: """A single pre or post bridge hook entry.""" run: str on_fail: str # "block" or "warn" @dataclass class BridgeHooks: """Parsed contents of .muse/bridge-hooks.toml.""" pre_bridge: list[BridgeHook] = field(default_factory=list) post_bridge: list[BridgeHook] = field(default_factory=list) def load_bridge_hooks(muse_root: pathlib.Path) -> BridgeHooks: """Read and validate ``.muse/bridge-hooks.toml``. Returns an empty :class:`BridgeHooks` when the file is absent. Raises ``SystemExit`` on malformed TOML or invalid ``on_fail`` values. """ import tomllib hooks_path = muse_root / ".muse" / "bridge-hooks.toml" if not hooks_path.exists(): return BridgeHooks() try: data = tomllib.loads(hooks_path.read_text()) except Exception as exc: print(f"Error: bridge-hooks.toml is invalid TOML: {exc}", file=sys.stderr) raise SystemExit(ExitCode.USER_ERROR) from exc def _parse_section(section_name: str) -> list[BridgeHook]: section = data.get(section_name, {}) raw_hooks = section.get("hooks", []) result: list[BridgeHook] = [] for i, entry in enumerate(raw_hooks): if "run" not in entry: print( f"Error: bridge-hooks.toml [{section_name}] hooks[{i}] is missing 'run'", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) on_fail = entry.get("on_fail", "block") if on_fail not in ("block", "warn"): print( f"Error: bridge-hooks.toml [{section_name}] hooks[{i}] " f"has invalid on_fail={on_fail!r}; must be 'block' or 'warn'", file=sys.stderr, ) raise SystemExit(ExitCode.USER_ERROR) result.append(BridgeHook(run=entry["run"], on_fail=on_fail)) return result return BridgeHooks( pre_bridge=_parse_section("pre_bridge"), post_bridge=_parse_section("post_bridge"), ) def run_hook(hook: BridgeHook, *, cwd: pathlib.Path, env: _Env) -> None: """Execute a single bridge hook in ``cwd`` with the given environment. Raises ``SystemExit`` when ``on_fail='block'`` and the process exits non-zero. Prints a warning and continues when ``on_fail='warn'`` and exits non-zero. """ import os import shlex merged_env = {**os.environ, **env} result = subprocess.run( shlex.split(hook.run), shell=False, cwd=cwd, env=merged_env, ) if result.returncode != 0: if hook.on_fail == "block": print( f"Error: bridge hook failed (exit {result.returncode}): {hook.run}", file=sys.stderr, ) raise SystemExit(result.returncode) else: print( f"Warning: bridge hook exited {result.returncode} (continuing): {hook.run}", file=sys.stderr, ) def run_hooks( hooks: list[BridgeHook], *, cwd: pathlib.Path, env: dict[str, str] ) -> None: """Run a list of bridge hooks in order. Stops immediately when a ``block`` hook fails (re-raises ``SystemExit``). Warn hooks that fail print a message but do not stop the chain. """ for hook in hooks: run_hook(hook, cwd=cwd, env=env)