diff --git a/all_repos/autofix_lib.py b/all_repos/autofix_lib.py
index 8407bee..8b2372e 100644
--- a/all_repos/autofix_lib.py
+++ b/all_repos/autofix_lib.py
@@ -27,7 +27,8 @@ class Commit(collections.namedtuple(
 
 
 class AutofixSettings(collections.namedtuple(
-        'AutofixSettings', ('jobs', 'color', 'limit', 'dry_run'),
+        'AutofixSettings',
+        ('jobs', 'color', 'limit', 'dry_run', 'interactive'),
 )):
     __slots__ = ()
 
@@ -35,7 +36,7 @@ class AutofixSettings(collections.namedtuple(
     def from_cli(cls, args):
         return cls(
             jobs=args.jobs, color=args.color, limit=args.limit,
-            dry_run=args.dry_run,
+            dry_run=args.dry_run, interactive=args.interactive,
         )
 
 
@@ -112,6 +113,43 @@ def repo_context(repo, *, use_color):
         traceback.print_exc()
 
 
+def _interactive_check(*, use_color):
+    def _quit():
+        print('Goodbye!')
+        raise SystemExit()
+
+    while True:
+        try:
+            s = input(color.fmt(
+                '***Looks good [y,n,s,q,?]? ',
+                color.BLUE_B, use_color=use_color,
+            ))
+        except (EOFError, KeyboardInterrupt):
+            _quit()
+
+        s = s.strip().lower()
+        if s in {'y', 'yes'}:
+            return True
+        elif s in {'n', 'no'}:
+            return False
+        elif s in {'s', 'shell'}:
+            print('Opening an interactive shell, type `exit` to continue.')
+            print('Any modifications will be committed.')
+            subprocess.call(os.environ.get('SHELL', 'bash'))
+        elif s in {'q', 'quit'}:
+            _quit()
+        else:
+            if s not in {'?', 'help'}:
+                print(color.fmt(
+                    f'Unexpected input: {s}', color.RED, use_color=use_color,
+                ))
+            print('y (yes): yes it looks good, commit and continue.')
+            print('n (no): no, do not commit this repository.')
+            print('s (shell): open an interactive shell in the repo.')
+            print('q (quit, ^C): early exit from the autofixer.')
+            print('? (help): show this help message.')
+
+
 def _fix_inner(repo, apply_fix, check_fix, config, commit, autofix_settings):
     with repo_context(repo, use_color=autofix_settings.color):
         branch_name = f'all-repos_autofix_{commit.branch_name}'
@@ -125,6 +163,12 @@ def _fix_inner(repo, apply_fix, check_fix, config, commit, autofix_settings):
 
         check_fix()
 
+        if (
+                autofix_settings.interactive and
+                not _interactive_check(use_color=autofix_settings.color)
+        ):
+            return
+
         commit_message = (
             f'{commit.msg}\n\n'
             f'Committed via https://github.com/asottile/all-repos'
@@ -153,6 +197,7 @@ def fix(
         commit: Commit,
         autofix_settings: AutofixSettings,
 ):
+    assert not autofix_settings.interactive or autofix_settings.jobs == 1
     repos = tuple(repos)[:autofix_settings.limit]
     func = functools.partial(
         _fix_inner,
diff --git a/all_repos/cli.py b/all_repos/cli.py
index 40f5177..2357878 100644
--- a/all_repos/cli.py
+++ b/all_repos/cli.py
@@ -40,8 +40,16 @@ def add_color_arg(parser):
 
 def add_fixer_args(parser):
     add_config_arg(parser)
-    add_jobs_arg(parser, default=1)
     add_color_arg(parser)
+
+    mutex = parser.add_mutually_exclusive_group()
+    mutex.add_argument('--dry-run', action='store_true')
+    mutex.add_argument(
+        '-i', '--interactive', help='Interactively approve / deny fixes.',
+    )
+    add_jobs_arg(mutex, default=1)
+
+    parser.add_argument('--limit', type=int, default=None)
     parser.add_argument(
         '--author',
         help=(
@@ -50,6 +58,4 @@ def add_fixer_args(parser):
             "An example: `--author='Herp Derp <herp.derp@umich.edu>'`"
         ),
     )
-    parser.add_argument('--dry-run', action='store_true')
-    parser.add_argument('--limit', type=int, default=None)
     parser.add_argument('--repos', nargs='*')
diff --git a/all_repos/color.py b/all_repos/color.py
index f2def75..0f9a61d 100644
--- a/all_repos/color.py
+++ b/all_repos/color.py
@@ -1,4 +1,5 @@
 BLUE_B = '\033[1;34m'
+RED = '\033[31m'
 RED_H = '\033[41m'
 TURQUOISE = '\033[36m'
 TURQUOISE_H = '\033[46;30m'

autofix_lib: --interactive
Add an interactive mode for autofixers which utilize `autofix_lib`
diff --git a/tests/autofix_lib_test.py b/tests/autofix_lib_test.py
index 9a1a57c..b3ab17d 100644
--- a/tests/autofix_lib_test.py
+++ b/tests/autofix_lib_test.py
@@ -1,5 +1,7 @@
+import builtins
 import os
 import subprocess
+from unittest import mock
 
 import pytest
 from pre_commit.constants import VERSION as PRE_COMMIT_VERSION
@@ -90,6 +92,120 @@ def test_repo_context_errors(file_config_files, capsys):
     assert 'assert False' in err
 
 
+def _get_input_side_effect(*inputs):
+    it = iter(inputs)
+
+    def side_effect(s):
+        print(s, end='')
+        ret = next(it)
+        if ret in (EOFError, KeyboardInterrupt):
+            print({EOFError: '^D', KeyboardInterrupt: '^C'}[ret])
+            raise ret
+        else:
+            print(f'<<{ret}')
+            return ret
+    return side_effect
+
+
+@pytest.yield_fixture
+def mock_input():
+    with mock.patch.object(builtins, 'input') as mck:
+        yield mck
+
+
+def test_interactive_control_c(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect(KeyboardInterrupt)
+    with pytest.raises(SystemExit):
+        autofix_lib._interactive_check(use_color=False)
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? ^C\n'
+        'Goodbye!\n'
+    )
+
+
+def test_interactive_eof(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect(EOFError)
+    with pytest.raises(SystemExit):
+        autofix_lib._interactive_check(use_color=False)
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? ^D\n'
+        'Goodbye!\n'
+    )
+
+
+def test_interactive_quit(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('q')
+    with pytest.raises(SystemExit):
+        autofix_lib._interactive_check(use_color=False)
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? <<q\n'
+        'Goodbye!\n'
+    )
+
+
+def test_interactive_yes(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('y')
+    assert autofix_lib._interactive_check(use_color=False) is True
+    out, _ = capfd.readouterr()
+    assert out == '***Looks good [y,n,s,q,?]? <<y\n'
+
+
+def test_interactive_no(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('n')
+    assert autofix_lib._interactive_check(use_color=False) is False
+    out, _ = capfd.readouterr()
+    assert out == '***Looks good [y,n,s,q,?]? <<n\n'
+
+
+def test_interactive_shell(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('s', 'n')
+    with mock.patch.dict(os.environ, {'SHELL': 'echo'}):
+        assert autofix_lib._interactive_check(use_color=False) is False
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? <<s\n'
+        'Opening an interactive shell, type `exit` to continue.\n'
+        'Any modifications will be committed.\n'
+        # A newline from echo
+        '\n'
+        '***Looks good [y,n,s,q,?]? <<n\n'
+    )
+
+
+def test_interactive_help(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('?', 'n')
+    assert autofix_lib._interactive_check(use_color=False) is False
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? <<?\n'
+        'y (yes): yes it looks good, commit and continue.\n'
+        'n (no): no, do not commit this repository.\n'
+        's (shell): open an interactive shell in the repo.\n'
+        'q (quit, ^C): early exit from the autofixer.\n'
+        '? (help): show this help message.\n'
+        '***Looks good [y,n,s,q,?]? <<n\n'
+    )
+
+
+def test_interactive_garbage(mock_input, capfd):
+    mock_input.side_effect = _get_input_side_effect('garbage', 'n')
+    assert autofix_lib._interactive_check(use_color=False) is False
+    out, _ = capfd.readouterr()
+    assert out == (
+        '***Looks good [y,n,s,q,?]? <<garbage\n'
+        'Unexpected input: garbage\n'
+        'y (yes): yes it looks good, commit and continue.\n'
+        'n (no): no, do not commit this repository.\n'
+        's (shell): open an interactive shell in the repo.\n'
+        'q (quit, ^C): early exit from the autofixer.\n'
+        '? (help): show this help message.\n'
+        '***Looks good [y,n,s,q,?]? <<n\n'
+    )
+
+
 def lower_case_f():
     f_contents = open('f').read()
     with open('f', 'w') as f:
@@ -110,7 +226,7 @@ def test_fix_dry_run_no_change(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', None),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=None, dry_run=True,
+            jobs=1, color=False, limit=None, dry_run=True, interactive=False,
         ),
     )
 
@@ -136,7 +252,7 @@ def test_fix_with_limit(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', None),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=1, dry_run=True,
+            jobs=1, color=False, limit=1, dry_run=True, interactive=False,
         ),
     )
 
@@ -148,6 +264,25 @@ def test_fix_with_limit(file_config_files, capfd):
     assert '-OHELLO\n+ohello\n' not in out
 
 
+def test_fix_interactive(file_config_files, capfd, mock_input):
+    mock_input.side_effect = _get_input_side_effect('y', 'n')
+    autofix_lib.fix(
+        (
+            str(file_config_files.output_dir.join('repo1')),
+            str(file_config_files.output_dir.join('repo2')),
+        ),
+        apply_fix=lower_case_f,
+        config=load_config(file_config_files.cfg),
+        commit=autofix_lib.Commit('message!', 'test-branch', None),
+        autofix_settings=autofix_lib.AutofixSettings(
+            jobs=1, color=False, limit=None, dry_run=False, interactive=True,
+        ),
+    )
+
+    assert file_config_files.dir1.join('f').read() == 'ohai\n'
+    assert file_config_files.dir2.join('f').read() == 'OHELLO\n'
+
+
 def test_autofix_makes_commits(file_config_files, capfd):
     autofix_lib.fix(
         (
@@ -158,7 +293,7 @@ def test_autofix_makes_commits(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', 'A B <a@a.a>'),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=None, dry_run=False,
+            jobs=1, color=False, limit=None, dry_run=False, interactive=False,
         ),
     )
 
@@ -201,7 +336,7 @@ def test_fix_failing_check_no_changes(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', None),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=None, dry_run=False,
+            jobs=1, color=False, limit=None, dry_run=False, interactive=False,
         ),
     )
 
@@ -226,7 +361,7 @@ def test_noop_does_not_commit(file_config_files, capfd):
         config=load_config(file_config_files.cfg),
         commit=autofix_lib.Commit('message!', 'test-branch', None),
         autofix_settings=autofix_lib.AutofixSettings(
-            jobs=1, color=False, limit=None, dry_run=False,
+            jobs=1, color=False, limit=None, dry_run=False, interactive=False,
         ),
     )
     rev_after1 = testing.git.revparse(file_config_files.dir1)

pip install -e .[dev]
parse_log_pytest
requirements.txt
pytest pytest-env
apt-get update apt-get install -y gcc
3.9
requirements-dev.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
-e git+https://github.com/asottile/all-repos.git@5c980cde22d85a79eec64f25fb0e31ffa0990c16#egg=all_repos
certifi==2025.1.31
cfgv==3.4.0
charset-normalizer==3.4.1
coverage==7.8.0
distlib==0.3.9
exceptiongroup==1.2.2
filelock==3.18.0
flake8==7.2.0
identify==2.6.9
idna==3.10
iniconfig==2.1.0
mccabe==0.7.0
nodeenv==1.9.1
packaging==24.2
platformdirs==4.3.7
pluggy==1.5.0
pre_commit==4.2.0
pycodestyle==2.13.0
pyflakes==3.3.2
pytest==8.3.5
pytest-env==1.1.5
PyYAML==6.0.2
requests==2.32.3
tomli==2.2.1
urllib3==2.3.0
virtualenv==20.29.3

name: all-repos
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - certifi==2025.1.31
      - cfgv==3.4.0
      - charset-normalizer==3.4.1
      - coverage==7.8.0
      - distlib==0.3.9
      - exceptiongroup==1.2.2
      - filelock==3.18.0
      - flake8==7.2.0
      - identify==2.6.9
      - idna==3.10
      - iniconfig==2.1.0
      - mccabe==0.7.0
      - nodeenv==1.9.1
      - packaging==24.2
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pre-commit==4.2.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.2
      - pytest==8.3.5
      - pytest-env==1.1.5
      - pyyaml==6.0.2
      - requests==2.32.3
      - tomli==2.2.1
      - urllib3==2.3.0
      - virtualenv==20.29.3
prefix: /opt/conda/envs/all-repos

tests/autofix_lib_test.py::test_interactive_control_c tests/autofix_lib_test.py::test_interactive_eof tests/autofix_lib_test.py::test_interactive_quit tests/autofix_lib_test.py::test_interactive_yes tests/autofix_lib_test.py::test_interactive_no tests/autofix_lib_test.py::test_interactive_shell tests/autofix_lib_test.py::test_interactive_help tests/autofix_lib_test.py::test_interactive_garbage tests/autofix_lib_test.py::test_fix_dry_run_no_change tests/autofix_lib_test.py::test_fix_with_limit tests/autofix_lib_test.py::test_fix_interactive tests/autofix_lib_test.py::test_autofix_makes_commits tests/autofix_lib_test.py::test_fix_failing_check_no_changes tests/autofix_lib_test.py::test_noop_does_not_commit
tests/autofix_lib_test.py::test_filter_repos[None-expected0] tests/autofix_lib_test.py::test_filter_repos[cli_repos1-expected1] tests/autofix_lib_test.py::test_filter_repos[cli_repos2-expected2] tests/autofix_lib_test.py::test_assert_importable_is_importable tests/autofix_lib_test.py::test_assert_importable_not_importable tests/autofix_lib_test.py::test_require_version_new_enough tests/autofix_lib_test.py::test_require_version_not_new_enough tests/autofix_lib_test.py::test_run tests/autofix_lib_test.py::test_cwd tests/autofix_lib_test.py::test_repo_context_success tests/autofix_lib_test.py::test_repo_context_errors
all_repos/color.py all_repos/cli.py all_repos/autofix_lib.py
all_repos/color.py all_repos/cli.py all_repos/autofix_lib.py