#!/usr/bin/env python3 """PostToolUse hook: format an edited file with the right formatter. Reads the hook JSON from stdin, works out which file(s) were just edited, and runs a language-appropriate formatter -- but, to avoid imposing a style on a project that never asked for it, every formatter here only runs when the project opts in via a config file discoverable by walking up from the edited file: stylua, ruff, prettier and taplo on their own configs, gofmt and rustfmt on go.mod / Cargo.toml, shfmt and fish_indent on .editorconfig. No marker, no formatting -- a repo that hand-formats its shell keeps its own layout. Two entry points, both wired in settings.json > hooks > PostToolUse: * Edit|Write -- the edited path is `tool_input.file_path`. * Bash -- under `defaultMode: auto` Claude is steered to edit through the shell (sed -i, heredocs, python), which the Edit|Write matcher never sees. There is no per-file event for that (`FileChanged` only watches literal filenames), so this scans `tool_input.command` for path-like tokens with a handled extension and formats the ones modified in the last few seconds -- the mtime gate keeps a parallel session's dirty files out of it. Best-effort: always exits 0 so a format hiccup never blocks an edit. jq is not guaranteed on the host, hence python for the stdin JSON parse. """ import fnmatch import json import os import re import shutil import subprocess import sys import time def find_up(start, names): """Nearest ancestor dir (incl. the file's own) containing any of `names`.""" d = os.path.dirname(os.path.abspath(start)) while True: if any(os.path.exists(os.path.join(d, n)) for n in names): return d parent = os.path.dirname(d) if parent == d: return None d = parent def _expand_braces(pat): """`a{b,c}` -> [`ab`, `ac`] (editorconfig sections use {,} alternation).""" m = re.search(r"\{([^{}]*)\}", pat) if not m: return [pat] out = [] for alt in m.group(1).split(","): out.extend(_expand_braces(pat[: m.start()] + alt + pat[m.end():])) return out def editorconfig_covers(path): """True if the nearest ancestor .editorconfig has a section matching `path`. Mere existence is NOT opt-in: this repo's .editorconfig is deliberately scoped to `[{*.sh,dotsync,dotpub}]` with no [*] block precisely so fish files stay untouched, and a generic upstream .editorconfig must not switch a formatter on for filetypes it never mentions. Patterns without a `/` match the basename (the editorconfig convention); ones with a `/` match the path relative to the .editorconfig's directory. """ d = find_up(path, [".editorconfig"]) if not d: return False name = os.path.basename(path) rel = os.path.relpath(os.path.abspath(path), d) try: with open(os.path.join(d, ".editorconfig"), errors="ignore") as fh: for line in fh: line = line.strip() if not (line.startswith("[") and line.endswith("]")): continue for pat in _expand_braces(line[1:-1]): target = rel if "/" in pat else name if fnmatch.fnmatch(target, pat.lstrip("/")): return True except Exception: pass return False def has_toml_section(start, filename, section): """True if the nearest ancestor `filename` contains `section`.""" d = find_up(start, [filename]) if not d: return False try: with open(os.path.join(d, filename), errors="ignore") as fh: return section in fh.read() except Exception: return False def stylua_cmd(path): exe = os.path.expanduser("~/.local/share/nvim/mason/bin/stylua") if not os.access(exe, os.X_OK): exe = shutil.which("stylua") if exe and find_up(path, [".stylua.toml", "stylua.toml"]): return [exe, path] return None def ruff_cmd(path): exe = shutil.which("ruff") if exe and (find_up(path, ["ruff.toml", ".ruff.toml"]) or has_toml_section(path, "pyproject.toml", "[tool.ruff")): return [exe, "format", path] return None _PRETTIER_CFGS = [ ".prettierrc", ".prettierrc.json", ".prettierrc.yaml", ".prettierrc.yml", ".prettierrc.json5", ".prettierrc.js", ".prettierrc.cjs", ".prettierrc.mjs", ".prettierrc.toml", "prettier.config.js", "prettier.config.cjs", "prettier.config.mjs", ] def prettier_cmd(path): exe = shutil.which("prettier") if exe and find_up(path, _PRETTIER_CFGS): return [exe, "--write", path] return None def gofmt_cmd(path): exe = shutil.which("gofmt") if exe and find_up(path, ["go.mod"]): # one canonical style return [exe, "-w", path] return None def rustfmt_cmd(path): exe = shutil.which("rustfmt") if exe and find_up(path, ["Cargo.toml", "rustfmt.toml", ".rustfmt.toml"]): return [exe, path] return None # Shell and fish are opt-in too, gated on an .editorconfig SECTION that matches the file # (shfmt reads .editorconfig natively for its settings, so the marker and the tool agree). # "One canonical style" turned out not to mean "the style the project uses": 3 of ~/.dots's # 25 fish files are not fish_indent-clean, so running these on sight rewrote whole files and # buried a 40-line change in a 200-line diff. ~/.dots opts its shell in via # `[{*.sh,dotsync,dotpub}]` while its fish files stay hand-formatted — existence of the # .editorconfig alone must not turn either formatter on. # Neovim agrees: conform.nvim there lists lua/css/html/ts/js only — never sh or fish. def fish_cmd(path): exe = shutil.which("fish_indent") if exe and editorconfig_covers(path): return [exe, "-w", path] return None def shfmt_cmd(path): exe = shutil.which("shfmt") if exe and editorconfig_covers(path): return [exe, "-w", path] return None def taplo_cmd(path): exe = shutil.which("taplo") # opt-in: TOML reflow/align is intrusive, so only where the project asked. if exe and find_up(path, ["taplo.toml", ".taplo.toml"]): return [exe, "format", path] return None HANDLERS = { ".lua": stylua_cmd, ".py": ruff_cmd, ".js": prettier_cmd, ".jsx": prettier_cmd, ".mjs": prettier_cmd, ".cjs": prettier_cmd, ".ts": prettier_cmd, ".tsx": prettier_cmd, ".css": prettier_cmd, ".scss": prettier_cmd, ".less": prettier_cmd, ".html": prettier_cmd, ".vue": prettier_cmd, ".json": prettier_cmd, ".md": prettier_cmd, ".yaml": prettier_cmd, ".yml": prettier_cmd, ".go": gofmt_cmd, ".rs": rustfmt_cmd, ".fish": fish_cmd, ".sh": shfmt_cmd, ".bash": shfmt_cmd, ".toml": taplo_cmd, } RECENT_SECS = 30 # a Bash edit is "ours" if the file changed this recently _EXT_ALT = "|".join(re.escape(e[1:]) for e in HANDLERS) _PATHISH = re.compile(r"[\w.~/@+-]+\.(?:" + _EXT_ALT + r")\b") def format_one(path): if not path or not os.path.isfile(path): return # Resolve symlinks before walking for markers: deployed dotfiles get edited via # their ~/.config/... links, but the opt-in configs live beside the real file in # the repo — a lexical walk from the link's path would never find them. path = os.path.realpath(path) handler = HANDLERS.get(os.path.splitext(path)[1].lower()) if not handler: return cmd = handler(path) if not cmd: return try: subprocess.run(cmd, timeout=15, capture_output=True) except Exception: pass def recent_paths_in_command(command, cwd): """Files a shell command plausibly just wrote: path-like tokens with a handled extension, resolved against the tool's cwd, that exist and changed within RECENT_SECS. Unchanged mentions (the source of a `cp`, a file merely grepped) fail the mtime gate.""" now = time.time() seen, out = set(), [] for tok in _PATHISH.findall(command or ""): p = os.path.expanduser(tok) if not os.path.isabs(p): p = os.path.join(cwd, p) p = os.path.normpath(p) if p in seen: continue seen.add(p) try: if os.path.isfile(p) and now - os.path.getmtime(p) <= RECENT_SECS: out.append(p) except OSError: pass return out def main(): try: data = json.load(sys.stdin) except Exception: return ti = data.get("tool_input") or {} tr = data.get("tool_response") or {} if data.get("tool_name") == "Bash": cwd = data.get("cwd") or os.getcwd() for p in recent_paths_in_command(ti.get("command"), cwd): format_one(p) return format_one(ti.get("file_path") or tr.get("filePath") or "") if __name__ == "__main__": main()