Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 

README.md

eol-normalizer

A Claude Code plugin that normalizes a file's working-tree line endings the moment you edit it. On every Write or Edit it resolves the file's .gitattributes eol= value via git check-attr and rewrites the file's line endings to match. Symmetric CRLF↔LF, idempotent, best-effort.

It uses your repository's own .gitattributes. It ships no policy and imposes no rules of its own.

Behavior

  • LF arm (every OS). A file resolving to eol=lf is normalized CRLF→LF.
  • CRLF arm (every OS). A file resolving to eol=crlf is normalized LF→CRLF. The hook compensates for tool writes that bypass git's checkout smudge, and such writes happen on any platform. An LF write to an eol=crlf path violates the repo's policy on Linux/macOS just as much as on Windows.
  • Binary guard. eol alone is not proof of text: under a broad * text=auto eol=lf rule, the eol attribute resolves to lf for binaries too. The hook mirrors gitattributes semantics. Explicit text is trusted, -text skips, and text=auto content-sniffs (NUL scan of the first 8000 bytes, git's own detection window), so binary files are never rewritten.
  • Unspecified → no-op. A path with no eol= attribute is left untouched. There is no hardcoded extension list. Resolution is entirely .gitattributes-driven, so narrow rules (a single eol=lf path) correctly win over broad ones (*.txt eol=crlf).
  • Advisory, never blocking. The hook always exits 0. Make a commit hook or CI (git add --renormalize, an EditorConfig check) your hard gate.

Requirements

  • Bash. The hook is a Bash script. On native Windows, install Git for Windows so Claude Code can run it under Git Bash.
  • jq on PATH. Parses the hook payload. Absent: the hook skips with a visible once-per-session notice. Install jq.
  • git on PATH. The attribute resolution (git check-attr) and repo-root detection depend on it. Without a git repository the hook is a quiet no-op (nothing to normalize against, not a missing prerequisite).

Rewriting uses perl when present, falling back to tr/awk otherwise, so no extra tooling is required.

The hook itself runs on Bash 3.2+. Telemetry timing uses EPOCHREALTIME (Bash 5.0+); on older bash the telemetry envelope is skipped while normalization still runs.

Hook budget accounting

Per docs/conventions/hook-budget/README.md, this hook is always-on for every Write and Edit, so its cost on the path where there is nothing to rewrite is the figure that counts. Measured on Windows 11 under Git Bash, twelve interleaved trials against an interleaved bash -c : floor (2026-09-02):

Event Fires Spawn-equivalents What changed
PostToolUse Write, already-normalized .md 1 41.0 before, 21.5 after (0.6.28) sixteen of twenty-seven processes gone: one git check-attr for both attributes, dirname and basename as parameter expansions, the NUL sniff as one read, and no temp file, cp, cmp or rm for a file that needs no rewrite

The residual is the shared library's payload reader and telemetry emitter, cut in 0.6.29 by the vendored hook-utils.sh (one batched realpath, no jq on the envelope). 0.6.41 drops leftover $(hook::repo_root) / $(hook::repo_relative_path) capture subshells around those helpers' _to forms; the git check-attr probe is unchanged.

hooks/hooks.json carries no if row, and that is deliberate (#3411). The sibling formatters filter by extension at the manifest so a Write of any other file spawns nothing, but this hook has no extension filter of its own: which files it normalizes is decided by the consuming repository's .gitattributes through git check-attr eol text, not by file type, so the set a declarative filter would have to reproduce is every file, and a file-type row would silently stop normalizing whatever it left out. What the manifest cannot filter the script keeps cheap: a file that already carries its eol= ending is decided by the plan step before any snapshot, so the kernel census (strace -f -e trace=clone,clone3,fork,vfork,execve, Linux x86_64, HOOK_TELEMETRY_SINK and CLAUDE_PROJECT_DIR unset, 2026-09-07, 0.6.42) on such a Write is 13 process creations and 6 execs (git three times: the working-tree probe, the root resolver and check-attr; jq, realpath, the hook's own bash) with no mktemp or cp.

Install

/plugin marketplace add melodic-software/claude-code-plugins
/plugin install eol-normalizer@<marketplace>

Then verify prerequisites with /eol-normalizer:setup check.

Configuration

The normalization policy itself is your repository's .gitattributes, which the hook reads automatically. To change which files normalize to which endings, edit your .gitattributes. One userConfig option tunes the hook's own behavior:

Option Type Default Effect
eol_normalizer_enabled boolean true Toggle the eol-normalizer hook. Set to false for a clean no-op.

Set it interactively with /plugin configure eol-normalizer@<marketplace>, or headless on the install command:

claude plugin install eol-normalizer@<marketplace> --config eol_normalizer_enabled=false

Options reference

Generated from this plugin's .claude-plugin/plugin.json. Every option Claude Code will prompt for when the plugin is enabled, with the environment variable each hook reads it from.

Option Type Default Environment variable Description
eol_normalizer_enabled boolean true CLAUDE_PLUGIN_OPTION_EOL_NORMALIZER_ENABLED Normalize a written file's line endings to its .gitattributes eol value

How to set these

Three supported routes, in the order most people want them:

  1. Interactively. Claude Code prompts for declared options when you enable the plugin. To change them later: /plugin configure eol-normalizer@<marketplace>.

  2. Headless. Repeat --config for each option. Replace <marketplace> with the marketplace you installed this plugin from:

    claude plugin install eol-normalizer@<marketplace> -s <scope> --config eol_normalizer_enabled=<value>

    The same command reconfigures a plugin that is already installed: it prints already installed and still writes the value. The short-circuit message is about the install, not the config write. Do not claude plugin uninstall to reconfigure: uninstalling drops this plugin's whole stored pluginConfigs entry, resetting every option in the table above to its default. -s defaults to user, so pass the scope claude plugin list reports for this plugin. The verified-version record lives in the plugin-reconfiguration convention.

    The value is stored immediately; the session you are in does not change. Hooks are handed their CLAUDE_PLUGIN_OPTION_* when the session starts, so start a fresh Claude Code session before expecting new behavior. A check run in the old session still reports the old value, and that is not a failed write.

  3. By hand, in settings. Add the value under pluginConfigs in your user settings (~/.claude/settings.json):

    {
      "pluginConfigs": {
        "eol-normalizer@<marketplace>": {
          "options": {
            "eol_normalizer_enabled": <value>
          }
        }
      }
    }

    Plugin option values are read from user, --settings, and managed settings only, not from a project's .claude/settings.json. To vary behavior per repository, enable or disable the plugin in that project's enabledPlugins instead of setting an option there.

Do not set the CLAUDE_PLUGIN_OPTION_* variables yourself. They are how Claude Code hands a configured value to a hook process; the value comes from the routes above.

Upstream documentation

Hook cost accounting

This plugin's PostToolUse hook matches every Write and Edit, so its cost is paid on every file the agent touches and it owes the marketplace's hook budget an honest figure.

Method. EPOCHREALTIME wall-clock around a direct hook invocation, 12 interleaved trials, each preceded by a bash -c : spawn-floor run so the reported ratio absorbs machine load. The payload is a PostToolUse Write naming a scratch file inside a repository whose .gitattributes says eol=lf, already LF, so the hook has nothing to rewrite. That benign path is the one that runs on nearly every edit. Windows 11 + Git Bash, 2026-09-02.

Counting. Both process columns come from a bash -x trace of that same invocation. An exec is a command in command position whose word resolves to a file rather than a builtin, function, alias or keyword. A fork is an increase in the trace's subshell-nesting depth, one per command substitution or subshell; it undercounts, because pipeline elements fork without changing the depth. Forks are reported beside execs because they are not free on this host: a command substitution measures about half the cost of a spawn, so twenty-seven of them are a large share of the run rather than a rounding error.

Host condition. The measuring host's bash -c : floor was 82 ms for the before run and 77 ms for the after run, against the convention's reference host of ≈ 80 ms. Absolute milliseconds from a loaded host are not comparable; the spawn-equivalent ratio is the figure that holds.

Benign Write, n=12 interleaved spawn-equivalents @ 80 ms reference host exec'd processes forks
Before (0.6.26) 41.0 ≈ 3,280 ms 27 32
After (0.6.28) 21.5 ≈ 1,720 ms 11 27

A benign edit costs about half what it did: ≈ 21.5 spawn-equivalents, ≈ 1,720 ms of reference-host work. The cut is process count, not algorithm. Sixteen of the twenty-seven processes were doing work a shell builtin does: two git check-attr calls became one, four dirname and one basename became parameter expansions, two tr pipelines became a substitution, the NUL sniff's head-tr-wc became one read, and the rewrite itself plus the mktemp, cp, cmp and rm that disclosed it are no longer reached for a file that is already in the target shape.

Residual, and why it stays. Eight to ten of the eleven remaining processes belong to the shared hooks/hook-utils.sh: payload validation, the file_path read and its project-membership scoping, and the repository-root lookup. That file is a registered byte-identical cross-plugin cluster, so changing it is a nine-plugin change and not this plugin's to make. Of what is left, one git is the repository root and one is the merged check-attr. A repository configured eol=crlf sees no cut on the files that arm touches: proving an LF-to-CRLF pass is unnecessary means finding a newline not preceded by a carriage return, which the chunked builtin probe cannot answer across chunk boundaries.

One deliberate deviation. A file this hook finds nothing to rewrite in is no longer opened for writing, so the hook no longer touches its mtime. No content and no reported message changes.

License

MIT (SPDX-License-Identifier: MIT).