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.
- LF arm (every OS). A file resolving to
eol=lfis normalized CRLF→LF. - CRLF arm (every OS). A file resolving to
eol=crlfis 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 aneol=crlfpath violates the repo's policy on Linux/macOS just as much as on Windows. - Binary guard.
eolalone is not proof of text: under a broad* text=auto eol=lfrule, theeolattribute resolves tolffor binaries too. The hook mirrors gitattributes semantics. Explicittextis trusted,-textskips, andtext=autocontent-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 singleeol=lfpath) 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.
- 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.
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.
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install eol-normalizer@<marketplace>Then verify prerequisites with /eol-normalizer:setup check.
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=falseGenerated 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 |
Three supported routes, in the order most people want them:
-
Interactively. Claude Code prompts for declared options when you enable the plugin. To change them later:
/plugin configure eol-normalizer@<marketplace>. -
Headless. Repeat
--configfor 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 installedand still writes the value. The short-circuit message is about the install, not the config write. Do notclaude plugin uninstallto reconfigure: uninstalling drops this plugin's whole storedpluginConfigsentry, resetting every option in the table above to its default.-sdefaults touser, so pass the scopeclaude plugin listreports 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. -
By hand, in settings. Add the value under
pluginConfigsin 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'senabledPluginsinstead 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.
- User configuration: the
userConfigschema and theCLAUDE_PLUGIN_OPTION_<KEY>export - Plugin install options: the
--configflag's reference entry - Plugins and skills settings:
enabledPlugins,extraKnownMarketplaces,pluginConfigs - Settings files and who they affect: user vs project vs local precedence
- Manage installed plugins: enabling, disabling,
/plugin list
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.
MIT (SPDX-License-Identifier: MIT).