A Claude Code plugin that lints and formats shell scripts the moment you edit
them. On every Write or Edit of a .sh or .bash file it runs
ShellCheck and (opt-in)
shfmt, surfacing findings back to Claude as
advisory context.
It uses your repository's own configuration, .shellcheckrc for linting
and .editorconfig for formatting. It ships no rules of its own.
- Spawned only for shell files. The hook is registered with the
iffiltersEdit(*.sh)andEdit(*.bash), so a Write/Edit of any other file never starts a hook process for it; the extension check inside the script is unchanged. - Lint on edit (always). ShellCheck (
warningseverity and above) runs on every edit. It is non-mutating; it only reports. - Format on edit (opt-in).
shfmtruns only when an.editorconfigsection names shell files: a shell glob such as[*.sh],[*.bash], or[*.{sh,bash}](including path-prefixed forms like[**/*.sh]), found by walking up from the file to the repository root. A bare[*]catch-all is not an opt-in: most repos only set line-ending / charset properties there, and treating that as a format gate would rewrite shell files to shfmt's built-in defaults. A repo whose.editorconfigonly configures other languages (or has none) likewise leaves shell files untouched. Path-only sections like[scripts/**]are also excluded; use an explicit shell glob. It runs with no parser/printer flags, so your.editorconfigis authoritative, and with--apply-ignoreso anignore = truesection (e.g. for generated or vendored scripts) is honored even on a single edited file. - Advisory, never blocking. The hook always exits
0. Findings are reported viaadditionalContext; they never reject the edit. Make a commit hook or CI your hard gate. - Config from the consumer. ShellCheck discovers
.shellcheckrcby walking up from the file's directory; shfmt reads.editorconfigthe same way. No working-directory assumptions. The tools are anchored to the edited file. - Gitignored files are skipped by default. A file the repository gitignores is not
linted or rewritten, silently (no notice), since a rewrite of an ignored file has no git
checkout to undo it. Set
bash_format_lint_gitignoredtotrueto act on them too. A tracked file that matches an ignore pattern stays in scope, and when git cannot decide (absent, no repository, an error) the hook acts as before. - Scope: files inside the current project, when
CLAUDE_PROJECT_DIRis set. WithCLAUDE_PROJECT_DIRset, the hook acts only on shell files under it (symlink-resolved): a.sh/.bashfile written outside the project, e.g. to a temp or scratchpad directory, is silently skipped (no lint, no format, no notice), deliberate defense-in-depth scoping inherited from the shared hook library. The OS temp tree (TMPDIR/TMP/TEMPand the POSIX defaults) is outside the project even when it sits underCLAUDE_PROJECT_DIR, the shape a home-directory project dir takes, where Claude Code's own session scratchpad would otherwise prefix-match as project content. The one exception is a project root that itself lives under temp (a fixture checkout built withmktemp -d), whose files are project content. Membership recognizes Windows 8.3 short-name spellings (KYLESE~1) of in-project paths, a per-volume concern: only volumes with 8.3 generation enabled produce such paths. IfCLAUDE_PROJECT_DIRis unset (e.g. some headless-psessions), the membership check is skipped and any existing edited file is processed. Either way, to lint a file the hook skipped, runshellcheckon it directly.
The jq notice appears once per session and agent, renewed every eighth skip. The shellcheck
and shfmt notices appear once per session, shared by all agents, renewed every eighth skip
with the install route kept.
- Bash. The hook is a Bash script. On native Windows, install Git for Windows so Claude Code can run it under Git Bash.
- Node.js on
PATH. Every hook row runs throughnode hooks/exec-bash.mjs, so withoutnodethe hook does not launch and shell edits are neither linted nor formatted. Claude Code's native installer does not ship or use Node.js; only its npm package needs it (setup docs, checked 2026-09-29; recheck when a Claude Code release note changes the installer or its runtime requirements). Unlike the tool notices below, a missingnodeshows no notice from this plugin. Check it with/bash-format:setup check. Install Node.js. - jq on
PATH. Parses the hook payload. Absent: the hook skips with a visible notice. Install jq. - ShellCheck on
PATHfor the lint pass. Absent: the lint pass skips with a visible notice. - shfmt on
PATHfor the format pass (and an.editorconfigin your repo to opt in). Absent while the repo opts in: the format pass skips with a visible notice. Without the.editorconfigopt-in the format pass stays quiet. The repo chose not to format.
A SessionStart probe reports a missing shfmt or shellcheck once per session, from
prerequisites.json, and the PostToolUse notices name the same install route. The probe and the
PostToolUse notice for a tool share one latch, so the probe's notice counts as the first and the
first PostToolUse notice stays silent until the renewal. Run /bash-format:check to see which
binaries resolve; it is read-only and installs nothing.
Each pass is independent: when a tool is absent its pass is skipped (visibly) and the other still runs.
The hook itself runs on Bash 3.2+. Telemetry timing uses EPOCHREALTIME
(Bash 5.0+); on older bash the telemetry envelope is skipped while linting and
formatting still run.
Per docs/conventions/hook-budget/README.md,
this hook is always-on for every Write and Edit of a .sh or .bash file (every handler in
hooks/hooks.json carries one of the two if rows, which keep every other extension from
spawning it, and the suite pins the whole handler set to the script's own extension set), so its
cost on a clean shell file is the figure that counts. Measured on Linux x86_64 under bash 5.2 in
a container with HOOK_TELEMETRY_SINK and CLAUDE_PROJECT_DIR unset, twelve interleaved trials
against an interleaved bash -c : floor S of about 4 ms, with a kernel census from
strace -f -e trace=clone,clone3,fork,vfork,execve (2026-09-07, 0.7.44):
| Event | Fires | Wall | Spawn-equivalents | Kernel census |
|---|---|---|---|---|
PostToolUse Write, clean .sh, no .editorconfig shell section |
1 | 45 ms | 10.5 | 14 process creations, 6 execs: shellcheck, two git rev-parse (the working-tree probe and the root resolver), jq, realpath, the hook's own bash |
PostToolUse Write, clean .sh, .editorconfig [*.sh] present |
1 | 55 ms | 14.1 | 31 to 32 process creations (the tools' own threads vary), 12 execs: the row above plus two shfmt and the disclosure snapshot's mktemp, cp, cmp, rm |
PostToolUse Write, any other extension |
0 | none | 0 | no process; the if rows drop the handler before a spawn |
At a 4 ms floor the spawn-equivalent column mostly measures the tools' own run time rather
than spawns, so the census column is the number that transfers to the Windows 11 Git Bash
reference host the convention calls binding; that host's figure for this plugin has not been
taken. The residual is ShellCheck and shfmt themselves plus the shared library's payload reader
(jq), working-tree probe and root resolver (git twice, realpath) and the disclosure
snapshot.
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install bash-format@<marketplace>Then verify prerequisites with /bash-format:setup check.
The linting and formatting rules come from the .shellcheckrc and
.editorconfig already in your repository, which the plugin reads automatically.
To change the rules, edit those files.
Two userConfig options tune the hook itself:
| Option | Default | Effect |
|---|---|---|
bash_format_enabled |
true |
Toggle for the bash-format hook; set false for a clean no-op. |
bash_format_lint_gitignored |
false |
Set true to lint and format files the repository gitignores; by default the hook skips them. |
Set it interactively with /plugin configure bash-format@<marketplace>, or headless on the
install command:
claude plugin install bash-format@<marketplace> --config bash_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 |
|---|---|---|---|---|
bash_format_enabled |
boolean | true |
CLAUDE_PLUGIN_OPTION_BASH_FORMAT_ENABLED |
Lint and format shell scripts on edit via ShellCheck + shfmt |
bash_format_lint_gitignored |
boolean | false |
CLAUDE_PLUGIN_OPTION_BASH_FORMAT_LINT_GITIGNORED |
By default the hook leaves a file the repository gitignores alone: it is not rewritten or linted, since a rewrite of an ignored file has no git checkout to undo it. Set true to act on gitignored files too. A tracked file that matches an ignore pattern is always in scope. |
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 bash-format@<marketplace>. -
Headless. Repeat
--configfor each option. Replace<marketplace>with the marketplace you installed this plugin from:claude plugin install bash-format@<marketplace> -s <scope> --config bash_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": { "bash-format@<marketplace>": { "options": { "bash_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
MIT (SPDX-License-Identifier: MIT).