A Claude Code plugin that spell-checks the moment you edit any file. On every
Write, Edit, or NotebookEdit it runs typos and
surfaces findings back to Claude as advisory context, including remediation
guidance for allowlisting a false positive. It is report-only by default;
with the typos_format_write_changes opt-in it applies typos' safe
corrections in place and reports every correction it applied.
It ships one fleet-wide protection of its own: a bundled
config/default-typos.toml injected via typos -c so write mode cannot
silently corrupt git SHAs. Otherwise it runs unconditionally on typos'
built-in spelling dictionary. If your repository has its own typos
configuration (typos.toml, _typos.toml, .typos.toml, Cargo.toml with
[workspace.metadata.typos]/[package.metadata.typos], or pyproject.toml
with [tool.typos]), typos discovers it from the target path and merges
extend-* keys with the bundled file rather than replacing them. No
opt-in required.
- Runs on every edit, zero-config or not. typos ships a built-in spelling dictionary and needs no configuration to be useful, so this hook never gates on a consumer typos config existing. When a config IS present, typos' own file-anchored discovery still finds and applies it (allowlist/exclude), in its documented precedence order. This plugin never re-implements that walk.
- Scan is language-agnostic; write is extension-scoped. The read-only scan
runs on any edited file (unlike sibling formatter plugins). Opt-in write mode
only calls
--write-changesfor an explicit allowlist of source, prose, and hand-edited config extensions. Unknown extensions, extensionless paths, and fixture/lock/binary-adjacent types stay report-only even whentypos_format_write_changesis on (#2650). - Report-only by default. A dictionary autocorrect is a content mutation you never asked for, and an unconditional writer here raced sibling formatter hooks on the same file with no defined ordering (#1809). Out of the box the hook reports findings and never modifies a file.
- Fix in place is an opt-in, then an allowlist. With
typos_format_write_changesset totrue,typos --write-changesapplies every correction it has confidence in, but only when the edited path's extension is on the write allowlist. Residual findings surface as advisory context, never auto-applied: an entry with no known correction (e.g. a blank-correctionextend-wordsentry marking a term "disallowed"), or one with more than one candidate correction. - Every applied rewrite is disclosed. A correction changes the content of your file, so the hook reports each one it applied (the word, its replacement, and the line) to Claude and to you, capped at ten per run with a count of the remainder. Nothing this hook writes is silent.
- Remediation guidance included. Both an applied rewrite and a residual
finding carry the fix: add the term to
extend-words/extend-identifiers(or anextend-ignore-repattern) in your typos config if it's intentional. This matters most on the applied path. The dictionary has no memory of your repair, so a word you correct by hand is rewritten again on the next edit until the allowlist entry exists. - Respects your excludes. The hook passes
--force-exclude, so a path your config's[files] exclude/extend-excludeexcludes (generated or vendored code, intentional-misspelling fixtures) is left untouched even though the hook passes it explicitly, with no advisory noise. - Gitignored paths are out of scope. A file the repository gitignores is
neither reported nor rewritten, matching hook-precision rule 6. Set
typos_format_lint_gitignoredtotrueto act on gitignored files too. A tracked file that matches an ignore pattern stays in scope. typos' own[files] extend-excludestill applies downstream when the hook does run. - 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.
Claude Code runs every matching PostToolUse hook in parallel for one tool
call, with no hook-level locking/ordering primitive. Under the report-only
default this hook only reads, so the worst concurrent outcome is a stale
finding. Opting typos_format_write_changes on in a repo where a sibling
formatter hook also rewrites the same file class (e.g. markdown-format on
.md, ruff-format on .py) re-opens the race: each hook independently
reads-then-writes with no locking, so ordering is last-writer-wins and a
nondeterministic clobber is possible. That double opt-in is your call to
make; the residual overlap class is tracked fleet-wide in #875.
Timeout tail. The handler sets "timeout": 15, well under the 600-second
default for a command hook, and Claude Code discards the output of a hook it
cancels at its timeout (hooks reference,
"Timeouts", checked 2026-09-27). In write mode the second typos pass rewrites
the file before the hook classifies what changed and discloses it, so a cancel
between the two leaves your file rewritten with no disclosure. The one measured
case that crossed 15 s, 10,000 residual findings at about 15.7 s, was fixed by
moving classification to a hash lookup (about 0.6 s); no current case has
reproduced the window. Report-only mode never writes, so it has no such tail.
The matcher is Write|Edit|NotebookEdit, so only those tools reach it. A file
written through the Bash tool (a heredoc, a redirect, sed -i), through
PowerShell, or through an MCP filesystem server's write tool is never
spell-checked. guardrails' block-hook-bypass, when installed, blocks the
common Bash redirect and heredoc forms, python3 -c writes that use a
file-write call it recognizes, and the PowerShell write cmdlets; sed -i,
perl -i, tee, a standalone cp, and other interpreters' one-liners such as
node -e are outside what it detects, and it does not see MCP tools. CI is
the only gate that sees every path. The matcher does not list MultiEdit: the
tools reference does not
list it among the built-in tools, and
permissions calls it "the legacy
MultiEdit tool" (both checked 2026-09-27; recheck if MultiEdit returns to
the tools reference).
- Bash. The hook is a Bash script. On native Windows, install
Git for Windows so
Claude Code can run it under Git Bash. If
/typos-format:setupfails to load on native Windows, Git Bash is missing: install Git for Windows and rerun (skills docs, checked 2026-09-29: ashell: bashskill fails before any command runs when Git Bash is not found). - Node.js on
PATH. The hook row runsnode hooks/exec-bash.mjs, which finds Bash and runs the script. Claude Code's native binary neither ships nor uses Node (setup, checked 2026-09-29), so withoutnodethe hook does not launch and spelling is not checked. A missingnodeis a hook launch error, not a skip notice, and/typos-format:setup checkreports it. - jq on
PATH. Parses the hook payload. Absent: the hook skips with a visible notice, once per session and agent, renewed every eighth skip. Install jq. - typos on
PATH. Unlike Ruff or markdownlint-cli2, typos has no per-repo dependency-manager convention. It is a standalone Rust binary, installed at the machine level (cargo, Homebrew, Conda, pacman, or a pre-built binary). typos is never downloaded on the fly; if it is not present, the hook skips with a visible notice, once per session (all agents share the latch), renewed every eighth skip with the install route kept. Install typos. A SessionStart probe reports a missingtyposonce per session, fromprerequisites.json, and the PostToolUse notice names the same install route. The two share one latch, so the probe's notice counts as the first and the first PostToolUse notice stays silent until the renewal. Run/typos-format:checkto see what resolves; it is read-only and installs nothing.
The hook itself runs on Bash 3.2+. Telemetry timing uses EPOCHREALTIME
(Bash 5.0+); on older bash the telemetry envelope is skipped while typo
fixing still runs.
Per docs/conventions/hook-budget/README.md,
this hook is always-on for every Write, Edit and NotebookEdit, so its cost on the path
where typos finds nothing is the figure that counts. Each row of the first table is interleaved trials against an
interleaved bash -c : floor on Windows 11 under Git Bash:
| Event | Fires | Spawn-equivalents | Measured | What changed |
|---|---|---|---|---|
PostToolUse Write, clean .md |
1 | 36.3 before, 26.0 after (0.6.35) | 2026-09-02, n=12 | three of sixteen processes gone: two dirname calls became parameter expansions and the notebook_path copy runs only for a payload that carries one |
PostToolUse Write, clean .md |
1 | 18.7 (0.6.55) | 2026-09-19, n=8, plugin-quality audit | the builtin field parser in the vendored hook-utils.sh answers where jq ran |
Linux wall time for 0.7.6, from hyperfine -N --warmup 3 --runs 30 on a clean .md Write
payload (Linux x86_64, bash 5.3, node 24, typos 1.49, 2026-09-29, three repeats):
PostToolUse Write, clean .md |
Mean wall time | What it is |
|---|---|---|
| Hook enabled, launcher plus script | 84 to 120 ms | node hooks/exec-bash.mjs spawns the script's bash: one node process on top of the script's own |
| Script alone | 58 to 73 ms | bash hooks/typos-format.sh |
| Hook disabled, launcher only | 37 to 45 ms | the launcher exits before bash, but the node process itself still starts |
bash -c : floor |
1 to 2 ms |
The host was loaded (load average near 30) and the repeats spread by about 40%, so read these as ranges and as a launcher cost of roughly 35 to 45 ms, not as absolute figures.
The 0.7.6 table is wall time on a Linux host, not spawn-equivalents: the floor there is about
1 ms, so a ratio to it says little, and no strace was available for a kernel census. The
Windows rows are the last spawn-equivalent figures. Releases after 0.6.55 have not been measured
on Windows, and the 0.6.35 and 0.6.55 figures and the 0.6.48 census below predate the launcher
and the current hooks/hook-utils.sh, so they do not describe the 0.7.6 process shape.
Reproduce the Linux table with hyperfine -N --input <payload.json> over the command in
hooks/hooks.json, with CLAUDE_PLUGIN_OPTION_TYPOS_FORMAT_ENABLED=false exported for the
disabled row.
The residual is the shared library's payload reader and telemetry emitter, cut in 0.6.36 by the
vendored hook-utils.sh (one batched realpath, no jq on the envelope), and the typos binary
itself.
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 the
read-only scan here is language-agnostic: the set a declarative file-type filter would have to
reproduce is every file, and a narrower row would silently stop scanning whatever it left out.
The write-mode allowlist is a separate, later decision inside the script and never gates the
scan, and NotebookEdit stays in the matcher for the same reason. 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.48) on a clean .md Write is 13 process
creations and 6 execs (typos, git twice for the working-tree probe and the root resolver,
jq, realpath, the hook's own bash).
/plugin marketplace add melodic-software/claude-code-plugins
/plugin install typos-format@<marketplace>Then verify prerequisites with /typos-format:setup check.
The rules themselves are never configured here. They come from the typos config already in your repository, which the plugin reads automatically. To change the rules (allowlist a false positive, ignore a pattern), edit that file.
Three userConfig options tune the hook itself:
| Option | Default | Effect |
|---|---|---|
typos_format_enabled |
true |
Kill switch. Set false for a clean no-op. |
typos_format_write_changes |
false |
Set true to apply corrections in place for write-allowlisted extensions (accepting last-writer-wins with any sibling formatter hook on the same file). Default is report-only: findings are reported, no file is modified. Denied extensions stay report-only even when this is on. |
typos_format_lint_gitignored |
false |
Set true to report (and, in write mode, rewrite) a file the repository gitignores. Off by default. |
Set them interactively with /plugin configure typos-format@<marketplace>, or headless on the
install command:
claude plugin install typos-format@<marketplace> --config typos_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 |
|---|---|---|---|---|
typos_format_enabled |
boolean | true |
CLAUDE_PLUGIN_OPTION_TYPOS_FORMAT_ENABLED |
Spell-check on edit of any file, unconditionally (report-only unless typos_format_write_changes is on) |
typos_format_write_changes |
boolean | false |
CLAUDE_PLUGIN_OPTION_TYPOS_FORMAT_WRITE_CHANGES |
Rewrite the file in place for write-allowlisted extensions. Off by default: findings are reported without modifying the file. Turning this on accepts last-writer-wins ordering with any sibling formatter hook that rewrites the same file. Unknown extensions stay report-only. |
typos_format_lint_gitignored |
boolean | false |
CLAUDE_PLUGIN_OPTION_TYPOS_FORMAT_LINT_GITIGNORED |
By default the hook leaves a file the repository gitignores alone: it is not rewritten or reported, 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 typos-format@<marketplace>. -
Headless. Repeat
--configfor each option. Replace<marketplace>with the marketplace you installed this plugin from:claude plugin install typos-format@<marketplace> -s <scope> --config typos_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": { "typos-format@<marketplace>": { "options": { "typos_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 matches every Write, Edit and NotebookEdit,
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 clean scratch file inside a repository, so typos finds nothing and
the hook takes the path 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-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 |
|---|---|---|---|---|
| Before (0.6.33) | 36.3 | ≈ 2,904 ms | 16 | 31 |
| After (0.6.35) | 26.0 | ≈ 2,080 ms | 13 | 29 |
On 0.6.35 a clean edit cost ≈ 26.0 spawn-equivalents, ≈ 2,080 ms of
reference-host work, down 28 percent. Two dirname calls became parameter
expansions, and the jq that copies notebook_path onto file_path now runs only
for a payload that carries one, which no Write or Edit does.
Later figures. A plugin-quality audit on 2026-09-19 measured 18.7 on 0.6.55 with the same method on the Windows host (n=8).
Residual, and why it stays. The dominant single cost is the typos binary's
own startup, which is the point of the hook. On the measuring host it resolves
through a WinGet Links shim, an indirection this hook cannot remove. Of the
remaining twelve 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 fleet-wide
change and not this plugin's to make. Two cygpath calls resolve the
repository-relative argument typos runs on, which the tool needs to apply the
repository's own exclude rules.
No extension gate is possible here, and that is deliberate. typos is
language-agnostic, so the scan has no allowlist to short-circuit on: gating it
by the write-mode allowlist would stop reporting typos in Dockerfile,
Makefile, .gitignore and every extensionless file. That is a behavior
change, not a saving.
A disabled hook costs a node process; an enabled edit costs node plus bash. The
hooks/hooks.json row runs node hooks/exec-bash.mjs --run-if-unset-or-true TYPOS_FORMAT_ENABLED
(0.7.1). With typos_format_enabled off the launcher exits before it resolves or spawns bash, so
node is the only process the hook creates. With it on, node spawns bash for the script, so the
edit path carries one more process than the pre-0.7.1 row, which exec'd the script in place of
its own shell. The 2026-09-29 Linux run in Hook budget accounting measured 37 to 45 ms disabled and
84 to 120 ms enabled, against 58 to 73 ms for the script alone. The earlier Windows figures for a disabled
row describe a row that no longer exists and are not repeated.
The row does not set async: true, and it is not split into an async report-only row and a
synchronous write-mode row. Running the report-only scan in the background would take it off the
per-edit critical path, but it gives up more than it saves:
- The finding would arrive late. A synchronous
PostToolUsehook'sadditionalContextreaches Claude alongside the tool result, while Claude is still on the file. An async hook's output arrives on the next conversation turn, and in an idle session it waits for your next message. The last edit of a task is exactly the one whose finding would land after Claude reports the task done. - Headless runs would lose findings. Under
claude -p, Claude Code kills an async hook that is still running at teardown and records it ascancelled, so the final edits of a scripted or cloud run would go unchecked. - The 15-second budget would go away. Claude Code does not enforce
timeouton an async hook, and every firing starts its own background process with no deduplication. This hook's classifier is sized against that budget. - The missing-
typosnotice would go quiet. Report-only findings already travel onadditionalContextalone; this hook setssystemMessageonly for a rewrite it applied (write mode) and for the notice (once per session, shared by all agents, renewed every eighth skip with the install route kept) thattyposis not onPATH. An async hook'ssystemMessageis not shown to you, so that notice would reach only Claude, once, and the skip would be invisible to the person who can install the binary.
The synchronous cost this keeps is 472 to 649 ms per edit on a Windows Git Bash host (2026-09-23,
recorded in #4677), and 27 ms for a clean file and 35 ms with a finding on Linux
x86_64 (typos-cli 1.50.3, 20 runs each, 2026-09-28). Write mode stays synchronous on its own
grounds: a background rewrite could race the next Edit of the same file, the reason async was
declined for eol-normalizer in #4417.
- Decision: keep the one synchronous row in both modes.
- Basis: hooks reference, "Run hooks in the
background": "After the background process exits, Claude Code delivers the
additionalContextandsystemMessagefields from the hook's JSON response to Claude on the next conversation turn. Unlike a synchronous hook'ssystemMessage, neither field is shown to you"; "If the session is idle, the response waits until the next user interaction"; "In non-interactive mode with the-pflag, Claude Code kills any async hook still running at teardown"; "Once an async hook is running in the background, Claude Code doesn't enforcetimeouton it". The same page'sPostToolUseoutput table:additionalContextis "added to Claude's context alongside the tool result". - As of: 2026-09-28.
- Recheck trigger: that section changes when async output is delivered, whether
-pwaits for a running async hook, or whethertimeoutapplies to one; or this hook's measured Windows cost on a clean edit exceeds one second.
MIT (SPDX-License-Identifier: MIT).