Add option to return non-zero on write, and Agent guidance (#4047)
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..ef2d8e6
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,92 @@
+# AGENTS.md
+
+This file provides guidance to AI coding agents when working with code in
+this repository.
+
+## AI-assistance policy (read first)
+
+codespell is maintained by a small number of volunteers. Code is reviewed
+and maintained by humans, so interactions about it should be between
+humans, too.
+
+- **The human opening the PR is the sole author.** Do not add AI
+  co-authorship trailers (`Co-Authored-By:` for Claude / Copilot / Cursor /
+  any other tool) to commits, and do not use a bot as the commit author.
+  The author takes full responsibility for the contents and must be able to
+  explain and defend every line on request.
+- **Do not write issues, PR descriptions, or comments for the human.**
+  Pasted AI-generated text makes it harder for reviewers to assess a
+  contribution. The human writes those in their own words, and discloses in
+  the PR description which AI tools were used and the scope of their
+  assistance.
+- **Optimize for the reviewer and for long-term maintainability, not for
+  output volume.** Every line you add is read by a volunteer reviewer and
+  then maintained by humans in perpetuity.
+
+## Keep changes small
+
+A big diff is a cost, not an accomplishment. Aim for the smallest change
+that delivers the user-visible behavior; anything else can be added later,
+when something actually needs it.
+
+- **Sketch before you generate.** For anything beyond a local fix,
+  summarize the design in a few sentences for the human you are working
+  with and confirm it is the smallest one that works before writing code.
+- **YAGNI.** Do not add options, config knobs, fallbacks, or "robustness"
+  the change does not need. Every new command-line option is documented,
+  tested, and supported forever, so it needs a demonstrated user need, not
+  a hypothetical one.
+- **Reuse before writing something new.** Look for an existing helper in
+  `codespell_lib/_codespell.py` and build on it rather than adding a
+  near-duplicate.
+- **Do not add dependencies** without asking first.
+- **Add to an existing test before writing a new one.** Extend a test in
+  `codespell_lib/tests/test_basic.py` that already sets up what you need,
+  then add a new test function there, and only then consider a new file. A
+  few compact assertions beat a long test that re-creates setup.
+- **For a bug fix, confirm the test fails without the fix and passes with
+  it.** A regression test that passes without the fix guards nothing.
+
+## Commands
+
+```bash
+pip install -e . --group dev        # editable install with test/lint deps
+pre-commit install --install-hooks  # ruff, mypy, codespell, file sorting, ...
+pytest codespell_lib/tests/test_basic.py -k write_changes  # CLI tests
+make check-dictionaries  # sort + validate dictionaries (test_dictionary.py)
+pre-commit run --all-files          # all linters, auto-fixing where possible
+make check               # everything CI checks: dictionaries, build, pytest, ruff
+```
+
+## Project layout
+
+Pure Python (>= 3.9), no runtime dependencies.
+
+- `codespell_lib/_codespell.py`: the CLI (`main()`), option parsing, and
+  file checking/fixing.
+- `codespell_lib/data/dictionary*.txt`: the misspelling dictionaries, one
+  per `--builtin` set.
+- `codespell_lib/tests/test_basic.py`: CLI behavior tests, mostly calling
+  `cs.main(...)` on files in `tmp_path`.
+- `codespell_lib/tests/test_dictionary.py`: dictionary format and
+  consistency checks.
+
+Dictionary entries are `typo->fix`; with more than one fix, end the line
+with a comma so none is applied automatically:
+
+```text
+calulated->calculated
+fiel->feel, field, file, phial,
+```
+
+See "Dictionary format" in `README.rst` for the full rules.
+
+## Boundaries
+
+- **Always:** run the relevant tests and
+  `pre-commit run --files <changed files>` before handing work back.
+- **Ask first:** new command-line options, new dependencies, and changes to
+  CI (`.github/`), `pyproject.toml`, or `.pre-commit-config.yaml`.
+- **Never:** hand-edit `dictionary_en_to_en-OX_AUTOGENERATED.txt` (it is
+  generated from `dictionary_en-GB_to_en-US.txt` by `make`), delete or
+  weaken a failing test to make it pass, or commit secrets.
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..b465f32
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,3 @@
+# CLAUDE.md
+
+See @AGENTS.md for guidance on working with this repository.
diff --git a/README.rst b/README.rst
index 420e133..5d0f288 100644
--- a/README.rst
+++ b/README.rst
@@ -59,6 +59,12 @@
 
 .. code-block:: sh
 
+    codespell -w --exit-non-zero-on-fix
+
+By default, ``-w`` exits with status 0 when every misspelling it finds was fixed. The ``--exit-non-zero-on-fix`` flag makes codespell exit with a non-zero status whenever it writes changes, which is useful in CI so that a run that modified files is reported as a failure.
+
+.. code-block:: sh
+
     codespell -I FILE, --ignore-words=FILE
 
 The ``-I`` flag can be used for a list of certain words to allow that are in the codespell dictionaries. The format of the file is one word per line. Invoke using: ``codespell -I path/to/file.txt`` to execute codespell referencing said list of allowed words. See `Ignoring Words`_ for more details.
diff --git a/codespell_lib/_codespell.py b/codespell_lib/_codespell.py
index 291d281..b46eec4 100644
--- a/codespell_lib/_codespell.py
+++ b/codespell_lib/_codespell.py
@@ -449,6 +449,14 @@
         default=False,
         help="write changes in place if possible",
     )
+    parser.add_argument(
+        "--exit-non-zero-on-fix",
+        action="store_true",
+        default=False,
+        help="exit with a non-zero status if any changes were written, "
+        "even if every misspelling was fixed. Has no effect without "
+        "-w/--write-changes or -i/--interactive.",
+    )
 
     parser.add_argument(
         "-D",
@@ -1200,7 +1208,7 @@
     uri_ignore_words: set[str],
     context: Optional[tuple[int, int]],
     options: argparse.Namespace,
-) -> int:
+) -> tuple[int, bool]:
     bad_count = 0
     fragments = None
 
@@ -1243,24 +1251,24 @@
 
         # ignore irregular files
         if not os.path.isfile(filename):
-            return bad_count
+            return bad_count, False
 
         try:
             text = is_text_file(filename)
         except PermissionError as e:
             print(f"WARNING: {e.strerror}: {filename}", file=sys.stderr)
-            return bad_count
+            return bad_count, False
         except OSError:
-            return bad_count
+            return bad_count, False
 
         if not text:
             if not options.quiet_level & QuietLevels.BINARY_FILE:
                 print(f"WARNING: Binary file: {filename}", file=sys.stderr)
-            return bad_count
+            return bad_count, False
         try:
             fragments, encoding = file_opener.open(filename)
         except OSError:
-            return bad_count
+            return bad_count, False
 
     # Parse lines.
     changed = False
@@ -1317,7 +1325,7 @@
                     )
             _write_file_atomically(filename, encoding, fragments)
 
-    return bad_count
+    return bad_count, changed
 
 
 def _write_file_atomically(
@@ -1569,6 +1577,7 @@
         )
 
     bad_count = 0
+    any_changed = False
     for filename in sorted(options.files):
         # ignore hidden files
         if is_hidden(filename, options.check_hidden):
@@ -1590,7 +1599,7 @@
                     fname = os.path.join(root, file_)
                     if glob_match.match(fname):  # skip paths
                         continue
-                    bad_count += parse_file(
+                    bad_count_update, changed = parse_file(
                         fname,
                         colors,
                         summary,
@@ -1605,6 +1614,8 @@
                         context,
                         options,
                     )
+                    bad_count += bad_count_update
+                    any_changed = any_changed or changed
 
                 # skip (relative) directories
                 dirs[:] = [
@@ -1615,7 +1626,7 @@
                 ]
 
         elif not glob_match.match(filename):  # skip files
-            bad_count += parse_file(
+            bad_count_update, changed = parse_file(
                 filename,
                 colors,
                 summary,
@@ -1630,10 +1641,13 @@
                 context,
                 options,
             )
+            bad_count += bad_count_update
+            any_changed = any_changed or changed
 
     if summary:
         print("\n-------8<-------\nSUMMARY:")
         print(summary)
     if options.count:
         print(bad_count, file=sys.stderr)
-    return EX_DATAERR if bad_count else EX_OK
+    failed = bad_count or (any_changed and options.exit_non_zero_on_fix)
+    return EX_DATAERR if failed else EX_OK
diff --git a/codespell_lib/tests/test_basic.py b/codespell_lib/tests/test_basic.py
index 796e869..1b03896 100644
--- a/codespell_lib/tests/test_basic.py
+++ b/codespell_lib/tests/test_basic.py
@@ -249,6 +249,14 @@
     corrected = fname.read_text()
     assert corrected == "This is abandoned\nAnd this is occurred\nAlso the typo\n"
 
+    # --exit-non-zero-on-fix fails only when changes were written
+    fname.write_text("abandonned\n")
+    assert cs_.main("-w", str(fname)) == EX_OK
+    fname.write_text("abandonned\n")
+    assert cs_.main("-w", "--exit-non-zero-on-fix", str(fname)) == EX_DATAERR
+    assert fname.read_text() == "abandoned\n"
+    assert cs_.main("-w", "--exit-non-zero-on-fix", str(fname)) == EX_OK
+
 
 def test_default_word_parsing(
     tmp_path: Path,