A Claude Code plugin that auto-formats and lints Markdown the moment you edit it.
On every Write or Edit of a .md or .mdc file it runs
markdownlint-cli2 --fix from
the file's repository root, applying every auto-fixable rule and surfacing the
residual (unfixable) findings back to Claude as advisory context.
It uses your repository's own markdownlint configuration. It ships none and imposes no rules of its own. A repository with no discoverable markdownlint config has chosen no Markdown style, so the hook does not run there at all (#1809): carrying a config is the opt-in.
- Markdown paths only at launch.
hooks.jsonregisters the handler onWrite|Editbut each copy carriesif: Edit(*.md)orif: Edit(*.mdc).Edit(path)is the permission-rule form that covers Write; aWrite(path)rule is never matched. A.txtWrite therefore never starts the process. It does not reach the script's in-script extension skip, and it cannot produce ahook_non_blocking_errorfor work this hook does not do (#2867). The script still checks the extension itself, because aniffilter is one rule per handler and fails open on an unparsable payload. - Config opt-in. The hook runs only when a markdownlint config file that
markdownlint-cli2would discover automatically (.markdownlint-cli2.jsonc,.markdownlint.json, …, any of the ten documented names) exists between the edited file's directory and the repository root. Without one, neither--fixrewrites nor default-rule findings are imposed, the same doctrine asbash-format's shfmt gate. Apackage.jsonmarkdownlint-cli2property does not open the gate: markdownlint-cli2 honors it only under an explicit--configflag, not by discovery. - Gitignored paths are out of scope. A file git excludes, a scratch tier
such as
.work/**, build output, or a vendored tree, is neither rewritten nor reported on. Your ignore rules already say which paths are not part of the reviewable artifact, so the hook reads them rather than asking for a second declaration. The verdict comes fromgit check-ignore, so it is git's full exclude machinery, not.gitignorealone: every.gitignorebetween the file and the repository root,$GIT_DIR/info/exclude, and your globalcore.excludesFile. A tracked file is never treated as ignored, even when a pattern matches it. Setmarkdown_format_lint_gitignoredtotrueto bypass this hook's git check. That is the only thing it bypasses: markdownlint-cli2 applies its ownignores/gitignoreconfig downstream, so a path your markdownlint config also excludes stays untouched even with the option on. When the verdict cannot be determined (nogitonPATH, no working tree,git check-ignoreerroring), the hook lints. A scope check that failed closed would disable the plugin invisibly. - Auto-fix on edit. Fixable violations (final newline, list-marker style,
trailing spaces, …) are corrected in place, and the count of fixes written is
reported to Claude and to you. A run that changed your file never passes
unannounced.
markdownlint-cli2reports no per-fix detail, so neither can this hook; the count is what there is. - Advisory, never blocking. The hook always exits
0. Unfixable findings are reported viaadditionalContext; they never reject the edit. Make a commit hook or CI your hard gate. - Bounded reporting. Every run reports the total finding count and the rules
that dominate it. Individual violation lines are capped (20 by default,
markdown_format_max_findings), and an unchanged finding set on a re-edited file reports its summary without repeating the detail. The linter's own banner lines never enter the report. A rule firing in bulk is a signal to configure that rule once in your markdownlint config, not to re-read it on every edit. - Config from the consumer.
markdownlint-cli2discovers config (.markdownlint-cli2.jsonc,.markdownlint.json, …) per edited file, from the file's directory up through its parents, so a nested config governs its subtree. The hookcds to the repository root before linting so that discovery caps at the root regardless of the session's working directory. A configuration that can execute code is gated on explicit approval. See Configuration trust boundary.
Claude Code runs every matching PostToolUse hook in parallel, with no
locking/ordering primitive. In an opted-in repo this hook is the single
in-place rewriter for .md/.mdc by default; a consumer who ALSO opts
typos-format's write mode on accepts last-writer-wins ordering between
the two on every Markdown edit. The residual overlap class across scoped
writer hooks is tracked fleet-wide in #875.
The hook requires the following tools:
- Bash 3.2 or later. On native Windows, install Git for Windows so Claude Code can run this Bash hook; WSL is also supported.
jqto parse hook input and emit structured context.markdownlint-cli2, installed explicitly either onPATHor as a pinned dependency in the consuming repository. For the latter, the hook uses the extensionlessnode_modules/.bin/markdownlint-cli2shim that npm supplies for POSIX shells and Git Bash. It resolves symlinks first and rejects a shim whose physical target escapes the repository'snode_modulestree.
Missing prerequisites do not block an edit. Following Claude Code's
PostToolUse contract,
the hook exits 0 and reports a once-per-session notice to both Claude
(additionalContext) and you (systemMessage). Only the notice latches.
The binary probe re-runs on every Markdown edit and recovers mid-session when
the tool becomes resolvable. A missing-markdownlint-cli2 notice includes a
PATH probed: line naming the plausible directories the hook process actually
searched (Claude Code plugin-bin entries collapse to a count). When
the edited file is outside a repository the notice names a durable user-scope
directory already on that PATH instead of recommending a repo-local
npm i -D. The hook never falls back to npx, installs a package, or
performs a network request during a hook run.
Telemetry timing uses EPOCHREALTIME (Bash 5.0+); on older Bash the telemetry
envelope is skipped while formatting still runs.
git is not required. Without it, formatting and linting still run; three
things that ask git a question degrade instead of blocking: the gitignore scope
lints rather than skipping (as above), the working-tree scope that applies when
CLAUDE_PROJECT_DIR is unset stops narrowing anything (config discovery is then
anchored at the edited file's own directory, so it opens only for a config
sitting there), and the repeat-report suppression stops deduplicating, so an
unchanged finding set is reported in full each time.
markdownlint-cli2 supports executable .cjs/.mjs configuration and can
load custom rules, Markdown-it plugins, and output formatters. Running it
under such configuration executes code the repository supplies. The hook
therefore never runs the linter under a code-loading configuration without an
explicit approval: it skips the lint run and reports a visible trust-gate
notice (once per session, on both the agent and user channels) naming the
risky files and the approval marker to create. To approve, review those files
and their installed dependencies, then create the marker directory using the
exact mkdir -p command the notice carries. The marker lives under
${CLAUDE_PLUGIN_DATA}/trust-approvals and is content-addressed over the
repository, its risky configuration files, and every repository file those
files' string literals resolve to (transitively, bounded), so a change to the
configuration or to a referenced repository module, including a branch switch
that swaps module bytes under an unchanged config, revokes the approval and
re-gates the run. When CLAUDE_PLUGIN_DATA is unavailable, the module scan
overflows its bound, or the configuration contains constructs that defeat
textual verification (string escapes or tags able to hide a module-loading
key), the gate fails closed and the lint run stays skipped. Declarative
rule-only JSONC/YAML configuration is unaffected and lints immediately.
Prefer it when executable configuration is unnecessary.
Per docs/conventions/hook-budget/README.md,
this hook is always-on for every Write and Edit of a .md or .mdc file (the two if
rows in hooks/hooks.json keep every other extension from spawning it), so its cost on a clean
Markdown edit 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, clean .md in a repo with a markdownlint config |
1 | 41.6 before, 32.0 after (0.11.38) | seven of twenty-one processes gone: six dirname calls and one basename are parameter expansions, including inside the config-discovery and risky-config walks |
PostToolUse Write, non-Markdown file |
0 | skipped by the if rows |
one entry per extension since 0.11.35 |
The residual is markdownlint-cli2 itself (one Node process per fire) plus the shared library's
payload reader and telemetry emitter, cut in 0.11.39 by the vendored hook-utils.sh. 0.11.51
drops leftover helper-capture and dirname forks on the root-resolution path
(resolve_repo_root_to, jq-absent opt-in probe, membership dirname); physical pwd -P
containment stays.
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install markdown-format@<marketplace>Then verify the runtime prerequisites with /markdown-format:setup check;
/markdown-format:setup apply resolves anything the check reports with
guidance, and /markdown-format:setup apply install-lint additionally
authorizes installing markdownlint-cli2 as a dev dependency using the
repository's own package manager.
The rules themselves are never configured here. The plugin's only rule source is the markdownlint config already in your repository, which it reads automatically. To change the rules, edit your repo's markdownlint config.
Which paths are in scope is also not configured here: the hook asks
git check-ignore and leaves the paths git excludes alone. That means your
.gitignore files, $GIT_DIR/info/exclude, and your global core.excludesFile
together. To exempt a path that git tracks, use markdownlint-cli2's own
ignores (or gitignore) key in a .markdownlint-cli2.* config. The hook
passes the edited file to
markdownlint-cli2, which applies those itself (verified against
markdownlint-cli2 v0.23.2; the tool's documentation does not state the behavior
for an explicitly named file, so confirm it against your own version).
Three userConfig options tune the hook itself:
| Option | Type | Default | Effect |
|---|---|---|---|
markdown_format_enabled |
boolean | true |
Toggle the markdown-format hook; set false for a clean no-op. |
markdown_format_lint_gitignored |
boolean | false |
Bypass this hook's git-ignore check. Off by default: an excluded path is neither rewritten nor reported on. Turning it on does not override markdownlint-cli2's own ignores / gitignore config, which still applies. |
markdown_format_max_findings |
number | 20 |
How many individual violations are listed per run. The total count and the leading rule codes are always reported regardless. 0 = unlimited. |
Set them interactively with /plugin configure markdown-format@<marketplace>, or headless on
the install command:
claude plugin install markdown-format@<marketplace> --config markdown_format_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 |
|---|---|---|---|---|
markdown_format_enabled |
boolean | true |
CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED |
Auto-format and lint Markdown on Write/Edit of .md/.mdc files (runs only when the repo carries a markdownlint config) |
markdown_format_lint_gitignored |
boolean | false |
CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_LINT_GITIGNORED |
By default the hook leaves gitignored files alone, since a scratch tier the repo excludes is neither rewritten nor reported on. Set true to bypass THIS HOOK's git check; markdownlint-cli2's own ignores/gitignore config still applies downstream, so a path your markdownlint config also excludes stays untouched. |
markdown_format_max_findings |
number min 0 |
20 |
CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_MAX_FINDINGS |
How many individual markdownlint violations are listed per run. The total count and the leading rule codes are always reported regardless. 0 = unlimited. |
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 markdown-format@<marketplace>. -
Headless. Repeat
--configfor each option. Replace<marketplace>with the marketplace you installed this plugin from:claude plugin install markdown-format@<marketplace> -s <scope> --config markdown_format_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": { "markdown-format@<marketplace>": { "options": { "markdown_format_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 is filtered to *.md and *.mdc by an if
condition on its two Write|Edit rows, so it costs nothing on any other file.
On a Markdown edit 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 clean scratch .md inside a repository that has a markdownlint config,
so the lint finds nothing and the hook takes the path that runs on nearly every
Markdown 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-nine 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 |
|---|---|---|---|---|
Clean .md, before (0.11.36) |
41.6 | ≈ 3,328 ms | 21 | 33 |
Clean .md, after (0.11.38) |
32.0 | ≈ 2,560 ms | 14 | 29 |
| Non-Markdown file, after | 4.4 | ≈ 352 ms | 1 | 9 |
A clean Markdown edit costs ≈ 32.0 spawn-equivalents, ≈ 2,560 ms of
reference-host work, down 23 percent. Six dirname calls and one basename
became parameter expansions, including inside the two directory walks whose
exec count grew with the file's depth below the repository root.
The non-Markdown row measures the in-script extension guard on its own. In a
live session the if condition rejects that payload before this hook is
spawned at all, so the row exists to show the guard is independently correct,
not to describe a cost anyone pays.
Residual, and why it stays. The dominant single cost is markdownlint-cli2,
a Node process, which is the point of the hook. Of the remaining thirteen
processes, eight to ten 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.
Two git calls resolve the repository root and the gitignore verdict. Two
grep calls scan the applicable markdownlint config for the keys that would let
it load code; those were left alone deliberately, because they run different
line-anchored patterns with different outcomes and a whole-file match in bash
would change $ from end-of-line to end-of-string. They are worth about 1.4
spawn-equivalents, which does not buy that semantic risk.
MIT (SPDX-License-Identifier: MIT).