diff --git a/app/Scredit.hs b/app/Scredit.hs
new file mode 100644
--- /dev/null
+++ b/app/Scredit.hs
@@ -0,0 +1,248 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE BangPatterns #-}
+
+module Main where
+
+import Scrappy.Grep.DSL
+import Scrappy.Grep.DSL.Parser (parseExpr)
+import Scrappy.Grep.DSL.Interpreter (interpret, InterpreterError(..))
+import Scrappy.Find (streamEdit)
+import Scrappy.Files (listFilesRecursive)
+import qualified Scrappy.Scrape
+
+import Options.Applicative
+import System.Exit (exitFailure, exitSuccess)
+import System.IO (hPutStrLn, stderr, stdin, hGetContents)
+import qualified System.Directory
+import System.Directory (doesDirectoryExist, doesFileExist)
+import System.FilePath (takeFileName, takeDirectory, (</>))
+import Data.List (isSuffixOf, isPrefixOf)
+import Control.Exception (catch, IOException)
+import Control.Monad (forM, when)
+
+data Options = Options
+  { optPattern      :: String
+  , optReplacement  :: String
+  , optTargets      :: [FilePath]
+  , optRecursive    :: Bool
+  , optExtensions   :: [String]
+  , optInclude      :: [String]
+  , optExclude      :: [String]
+  , optExcludeDir   :: [String]
+  , optInPlace      :: Bool
+  , optVerbose      :: Bool
+  , optDryRun       :: Bool
+  } deriving Show
+
+optionsParser :: Parser Options
+optionsParser = Options
+  <$> strArgument
+      ( metavar "PATTERN"
+     <> help "Parsec DSL pattern (e.g., 'some digit', 'string \"TODO\"')"
+      )
+  <*> strOption
+      ( long "replace"
+     <> short 'r'
+     <> metavar "STRING"
+     <> help "Replacement string"
+      )
+  <*> many (strArgument
+      ( metavar "FILE..."
+     <> help "Files or directories to edit (use - for stdin)"
+      ))
+  <*> switch
+      ( long "recursive"
+     <> short 'R'
+     <> help "Recursively search directories"
+      )
+  <*> many (strOption
+      ( long "extension"
+     <> short 'e'
+     <> metavar "EXT"
+     <> help "Only edit files with extension (e.g., .hs, .txt)"
+      ))
+  <*> many (strOption
+      ( long "include"
+     <> metavar "GLOB"
+     <> help "Only edit files matching GLOB pattern"
+      ))
+  <*> many (strOption
+      ( long "exclude"
+     <> metavar "GLOB"
+     <> help "Skip files matching GLOB pattern"
+      ))
+  <*> many (strOption
+      ( long "exclude-dir"
+     <> metavar "GLOB"
+     <> help "Skip directories matching GLOB pattern"
+      ))
+  <*> switch
+      ( long "in-place"
+     <> short 'i'
+     <> help "Edit files in place (instead of printing to stdout)"
+      )
+  <*> switch
+      ( long "verbose"
+     <> short 'v'
+     <> help "Show verbose output"
+      )
+  <*> switch
+      ( long "dry-run"
+     <> short 'n'
+     <> help "Show what would be changed without making changes"
+      )
+
+main :: IO ()
+main = do
+  opts <- execParser $ info (optionsParser <**> helper)
+    ( fullDesc
+   <> progDesc "Find and replace using Parsec parser patterns"
+   <> header "scredit - sed with parser combinators"
+    )
+
+  let targets = if null (optTargets opts) then ["-"] else optTargets opts
+      opts' = opts { optTargets = targets }
+
+  case parseExpr (optPattern opts') of
+    Left err -> do
+      hPutStrLn stderr $ "Pattern parse error: " ++ show err
+      exitFailure
+    Right ast -> do
+      if containsRef ast
+        then do
+          hPutStrLn stderr "Error: scredit does not yet support 'ref' patterns"
+          hPutStrLn stderr "Use inline patterns only (e.g., 'some digit', 'string \"TODO\"')"
+          exitFailure
+        else runEdit opts' ast
+
+runEdit :: Options -> ParserExpr -> IO ()
+runEdit opts ast = do
+  case interpret ast of
+    Left (UnknownRef name) -> do
+      hPutStrLn stderr $ "Error: Unknown reference '" ++ name ++ "'"
+      exitFailure
+    Right parser -> do
+      if "-" `elem` optTargets opts
+        then do
+          content <- hGetContents stdin
+          let edited = streamEdit parser (const $ optReplacement opts) content
+          putStr edited
+        else do
+          files <- gatherFiles opts (optTargets opts)
+          editFiles opts parser files
+
+editFiles :: Options -> Scrappy.Scrape.ScraperT String -> [FilePath] -> IO ()
+editFiles opts parser files = do
+  forM_ files $ \fp -> editFile opts parser fp
+  where
+    forM_ xs f = mapM_ f xs
+
+editFile :: Options -> Scrappy.Scrape.ScraperT String -> FilePath -> IO ()
+editFile opts parser fp = do
+  result <- (Just <$> readFile fp) `catch` (\(_ :: IOException) -> return Nothing)
+  case result of
+    Nothing -> return ()
+    Just content -> do
+      let !_ = length content  -- Force to catch encoding errors
+          replacement = optReplacement opts
+          edited = streamEdit parser (const replacement) content
+          changed = content /= edited
+
+      when (optVerbose opts && changed) $
+        hPutStrLn stderr $ "Editing: " ++ fp
+
+      if optInPlace opts
+        then do
+          when changed $ do
+            if optDryRun opts
+              then hPutStrLn stderr $ "Would edit: " ++ fp
+              else writeFile fp edited
+        else do
+          when changed $ putStr edited
+
+-- | Gather all files to edit based on options
+gatherFiles :: Options -> [FilePath] -> IO [FilePath]
+gatherFiles opts targets = do
+  let fileTargets = filter (/= "-") targets
+  allFiles <- concat <$> mapM (expandTarget opts) fileTargets
+  let filtered = filterByExtension (optExtensions opts) allFiles
+  return $ filterExcludes opts $ filterIncludes opts filtered
+
+filterIncludes :: Options -> [FilePath] -> [FilePath]
+filterIncludes opts files =
+  case optInclude opts of
+    [] -> files
+    patterns -> filter (matchesAnyGlob patterns . takeFileName) files
+
+filterExcludes :: Options -> [FilePath] -> [FilePath]
+filterExcludes opts files =
+  let excludeFile = not . matchesAnyGlob (optExclude opts) . takeFileName
+      excludeDir = not . anyParentMatches (optExcludeDir opts)
+  in filter (\f -> excludeFile f && excludeDir f) files
+
+anyParentMatches :: [String] -> FilePath -> Bool
+anyParentMatches patterns fp =
+  let dirs = splitPath fp
+  in any (matchesAnyGlob patterns) dirs
+  where
+    splitPath p = case takeDirectory p of
+      "." -> []
+      "/" -> []
+      parent -> takeFileName parent : splitPath parent
+
+matchesAnyGlob :: [String] -> String -> Bool
+matchesAnyGlob patterns str = any (`simpleGlobMatch` str) patterns
+
+simpleGlobMatch :: String -> String -> Bool
+simpleGlobMatch [] [] = True
+simpleGlobMatch [] _ = False
+simpleGlobMatch ('*':rest) str = any (simpleGlobMatch rest) (tails str)
+simpleGlobMatch ('?':rest) (_:str) = simpleGlobMatch rest str
+simpleGlobMatch ('?':_) [] = False
+simpleGlobMatch (c:rest) (s:str) = c == s && simpleGlobMatch rest str
+simpleGlobMatch _ [] = False
+
+tails :: [a] -> [[a]]
+tails [] = [[]]
+tails xs@(_:rest) = xs : tails rest
+
+expandTarget :: Options -> FilePath -> IO [FilePath]
+expandTarget opts target = do
+  isDir <- doesDirectoryExist target
+  isFile <- doesFileExist target
+  case (isDir, isFile) of
+    (True, _) ->
+      if optRecursive opts
+        then listFilesRecursiveFiltered (optExcludeDir opts) target
+        else do
+          hPutStrLn stderr $ "Warning: " ++ target ++ " is a directory. Use -R to edit recursively."
+          return []
+    (_, True) -> return [target]
+    _ -> do
+      hPutStrLn stderr $ "Warning: " ++ target ++ " not found"
+      return []
+
+listFilesRecursiveFiltered :: [String] -> FilePath -> IO [FilePath]
+listFilesRecursiveFiltered excludeDirs dir = go dir `catch` (\(_ :: IOException) -> return [])
+  where
+    go d = do
+      contents <- System.Directory.listDirectory d
+      paths <- forM contents $ \name -> do
+        let fullPath = d </> name
+        isDir <- doesDirectoryExist fullPath
+        if isDir
+          then if matchesAnyGlob excludeDirs name
+               then return []
+               else listFilesRecursiveFiltered excludeDirs fullPath
+          else do
+            absPath <- System.Directory.makeAbsolute fullPath
+            return [absPath]
+      return (concat paths)
+
+filterByExtension :: [String] -> [FilePath] -> [FilePath]
+filterByExtension [] files = files
+filterByExtension exts files = filter hasExt files
+  where
+    hasExt fp = any (`isSuffixOf` fp) normalizedExts
+    normalizedExts = map ensureDot exts
+    ensureDot ext = if "." `isPrefixOf` ext then ext else "." ++ ext
diff --git a/screp.cabal b/screp.cabal
--- a/screp.cabal
+++ b/screp.cabal
@@ -1,6 +1,6 @@
 cabal-version:       2.4
 name:                screp
-version:             0.2.0.0
+version:             0.3.0.0
 synopsis:            grep-like CLI using Parsec parsers instead of regex
 description:
         screp is a grep-like command-line tool that uses Parsec parser
@@ -55,6 +55,18 @@
   default-language:   Haskell2010
   ghc-options:        -Wall -threaded -rtsopts
 
+executable scredit
+  main-is:            Scredit.hs
+  build-depends:       base >=4.19.0 && <5
+                     , screp
+                     , scrappy-core >=0.1.0.1 && <0.2
+                     , optparse-applicative >=0.14 && <0.19
+                     , directory >=1.3.8 && <1.4
+                     , filepath >=1.5.4 && <1.6
+  hs-source-dirs:     app
+  default-language:   Haskell2010
+  ghc-options:        -Wall -threaded -rtsopts
+
 test-suite screp-test
   type:               exitcode-stdio-1.0
   main-is:            Test.hs
@@ -64,6 +76,20 @@
                      , directory >=1.3.8 && <1.4
                      , filepath >=1.5.4 && <1.6
                      , parsec >=3.1.18 && <3.2
+  hs-source-dirs:     test
+  default-language:   Haskell2010
+  ghc-options:        -Wall
+
+test-suite scredit-test
+  type:               exitcode-stdio-1.0
+  main-is:            ScreditTest.hs
+  build-depends:       base >=4.19.0 && <5
+                     , screp
+                     , scrappy-core >=0.1.0.1 && <0.2
+                     , directory >=1.3.8 && <1.4
+                     , filepath >=1.5.4 && <1.6
+                     , parsec >=3.1.18 && <3.2
+                     , process >=1.6 && <1.7
   hs-source-dirs:     test
   default-language:   Haskell2010
   ghc-options:        -Wall
diff --git a/test/ScreditTest.hs b/test/ScreditTest.hs
new file mode 100644
--- /dev/null
+++ b/test/ScreditTest.hs
@@ -0,0 +1,967 @@
+{-# LANGUAGE ScopedTypeVariables #-}
+
+module Main where
+
+import Scrappy.Find (streamEdit)
+import Scrappy.Grep.DSL.Parser (parseExpr)
+import Scrappy.Grep.DSL.Interpreter (interpret)
+import Text.Parsec (string, many1, digit, letter, anyChar, (<|>), try)
+
+import System.Exit (exitFailure, exitSuccess, ExitCode(..))
+import System.Directory (createDirectoryIfMissing, removeDirectoryRecursive, doesDirectoryExist, doesFileExist)
+import System.FilePath ((</>))
+import System.Process (readProcessWithExitCode)
+import Control.Monad (when)
+import Control.Exception (catch, IOException)
+import Data.List (isInfixOf, isPrefixOf)
+
+main :: IO ()
+main = do
+  putStrLn "Running scredit tests...\n"
+
+  -- Run all tests
+  results <- sequence
+    -- Unit tests
+    [ testStreamEditBasic
+    , testStreamEditMultiple
+    , testStreamEditNoMatch
+    , testStreamEditEmpty
+    , testStreamEditEmptyReplacement
+    , testStreamEditOverlapping
+    , testStreamEditAtBoundaries
+    , testStreamEditConsecutive
+    , testStreamEditUnicode
+    , testStreamEditNewlines
+    , testGlobWildcard
+    , testGlobQuestion
+    , testGlobLiteral
+    , testGlobEdgeCases
+    , testGlobCaseSensitive
+    -- Integration tests
+    , testHelpFlag
+    , testMissingReplace
+    , testInvalidPattern
+    , testRefPatternRejected
+    , testStdinDefault
+    , testStdinReplacement
+    , testStdinNoMatch
+    , testSingleFile
+    , testMultipleFiles
+    , testFileNotFound
+    , testInPlaceEdit
+    , testInPlaceNoChange
+    , testStdoutDefault
+    , testDryRunNoModify
+    , testDryRunMessage
+    , testVerboseOutput
+    , testVerboseOnlyChanged
+    , testExtensionFilter
+    , testRecursiveRequired
+    , testRecursiveDescend
+    , testRecursiveWithFilters
+    , testPatternSomeDigit
+    , testPatternString
+    , testPatternComplex
+    , testExitSuccess
+    , testExitFailPattern
+    -- Additional coverage tests
+    , testStdinExplicit
+    , testIncludePattern
+    , testExcludePattern
+    , testExcludeDir
+    , testCombinedFilters
+    , testMultipleExtensions
+    , testExtensionNormalize
+    , testInPlaceMultiple
+    , testDryRunWithVerbose
+    ]
+
+  -- Summary
+  let passed = length (filter id results)
+      total = length results
+  putStrLn $ "\n" ++ show passed ++ "/" ++ show total ++ " tests passed"
+
+  if and results
+    then exitSuccess
+    else exitFailure
+
+--------------------------------------------------------------------------------
+-- Test Infrastructure
+--------------------------------------------------------------------------------
+
+-- | Run scredit with given args and stdin, return (exitcode, stdout, stderr)
+runScredit :: [String] -> String -> IO (ExitCode, String, String)
+runScredit args stdin = do
+  -- First build to ensure executable exists
+  _ <- readProcessWithExitCode "cabal" ["build", "scredit"] ""
+  -- Run via cabal run
+  readProcessWithExitCode "cabal" (["run", "scredit", "--"] ++ args) stdin
+
+-- | Create temp directory, run action, cleanup
+withTestDir :: String -> (FilePath -> IO Bool) -> IO Bool
+withTestDir name action = do
+  let dir = "/tmp/scredit-test-" ++ name
+  createDirectoryIfMissing True dir
+  result <- action dir `catch` (\(_ :: IOException) -> cleanup dir >> return False)
+  cleanup dir
+  return result
+
+-- | Cleanup directory
+cleanup :: FilePath -> IO ()
+cleanup dir = do
+  exists <- doesDirectoryExist dir
+  when exists $ removeDirectoryRecursive dir
+
+-- | Assert file contents match expected
+assertFileContents :: FilePath -> String -> IO Bool
+assertFileContents fp expected = do
+  exists <- doesFileExist fp
+  if not exists
+    then return False
+    else do
+      actual <- readFile fp
+      return (actual == expected)
+
+--------------------------------------------------------------------------------
+-- Unit Tests: streamEdit
+--------------------------------------------------------------------------------
+
+testStreamEditBasic :: IO Bool
+testStreamEditBasic = do
+  putStrLn "=== Unit: streamEdit Basic ==="
+  let result = streamEdit (string "foo") (const "bar") "hello foo world"
+  if result == "hello bar world"
+    then do
+      putStrLn "  PASS: Single match replaced"
+      return True
+    else do
+      putStrLn $ "  FAIL: Expected 'hello bar world', got '" ++ result ++ "'"
+      return False
+
+testStreamEditMultiple :: IO Bool
+testStreamEditMultiple = do
+  putStrLn "\n=== Unit: streamEdit Multiple ==="
+  let result = streamEdit (string "a") (const "X") "abracadabra"
+  if result == "XbrXcXdXbrX"
+    then do
+      putStrLn "  PASS: Multiple matches replaced"
+      return True
+    else do
+      putStrLn $ "  FAIL: Expected 'XbrXcXdXbrX', got '" ++ result ++ "'"
+      return False
+
+testStreamEditNoMatch :: IO Bool
+testStreamEditNoMatch = do
+  putStrLn "\n=== Unit: streamEdit No Match ==="
+  let result = streamEdit (string "xyz") (const "abc") "hello world"
+  if result == "hello world"
+    then do
+      putStrLn "  PASS: No match preserves input"
+      return True
+    else do
+      putStrLn $ "  FAIL: Expected 'hello world', got '" ++ result ++ "'"
+      return False
+
+testStreamEditEmpty :: IO Bool
+testStreamEditEmpty = do
+  putStrLn "\n=== Unit: streamEdit Empty ==="
+  let result = streamEdit (string "x") (const "y") ""
+  if result == ""
+    then do
+      putStrLn "  PASS: Empty input returns empty"
+      return True
+    else do
+      putStrLn $ "  FAIL: Expected '', got '" ++ result ++ "'"
+      return False
+
+testStreamEditEmptyReplacement :: IO Bool
+testStreamEditEmptyReplacement = do
+  putStrLn "\n=== Unit: streamEdit Empty Replacement ==="
+  let result = streamEdit (string "remove") (const "") "please remove this"
+  if result == "please  this"
+    then do
+      putStrLn "  PASS: Empty replacement deletes match"
+      return True
+    else do
+      putStrLn $ "  FAIL: Expected 'please  this', got '" ++ result ++ "'"
+      return False
+
+testStreamEditOverlapping :: IO Bool
+testStreamEditOverlapping = do
+  putStrLn "\n=== Unit: streamEdit Overlapping ==="
+  -- "aa" pattern on "aaa" should match first "aa", leave "a"
+  let result = streamEdit (string "aa") (const "X") "aaa"
+  if result == "Xa"
+    then do
+      putStrLn "  PASS: Overlapping patterns - first wins"
+      return True
+    else do
+      putStrLn $ "  FAIL: Expected 'Xa', got '" ++ result ++ "'"
+      return False
+
+testStreamEditAtBoundaries :: IO Bool
+testStreamEditAtBoundaries = do
+  putStrLn "\n=== Unit: streamEdit At Boundaries ==="
+  let result1 = streamEdit (string "start") (const "X") "start middle end"
+      result2 = streamEdit (string "end") (const "X") "start middle end"
+  if result1 == "X middle end" && result2 == "start middle X"
+    then do
+      putStrLn "  PASS: Matches at start and end of string"
+      return True
+    else do
+      putStrLn $ "  FAIL: start='" ++ result1 ++ "', end='" ++ result2 ++ "'"
+      return False
+
+testStreamEditConsecutive :: IO Bool
+testStreamEditConsecutive = do
+  putStrLn "\n=== Unit: streamEdit Consecutive ==="
+  let result = streamEdit (string "ab") (const "X") "ababab"
+  if result == "XXX"
+    then do
+      putStrLn "  PASS: Consecutive matches all replaced"
+      return True
+    else do
+      putStrLn $ "  FAIL: Expected 'XXX', got '" ++ result ++ "'"
+      return False
+
+testStreamEditUnicode :: IO Bool
+testStreamEditUnicode = do
+  putStrLn "\n=== Unit: streamEdit Unicode ==="
+  let result = streamEdit (string "hello") (const "hola") "hello \228\184\150\231\149\140"
+  if result == "hola \228\184\150\231\149\140"
+    then do
+      putStrLn "  PASS: Unicode preserved"
+      return True
+    else do
+      putStrLn $ "  FAIL: Expected 'hola \228\184\150\231\149\140', got '" ++ result ++ "'"
+      return False
+
+testStreamEditNewlines :: IO Bool
+testStreamEditNewlines = do
+  putStrLn "\n=== Unit: streamEdit Newlines ==="
+  let input = "line1\nline2\nline3"
+      result = streamEdit (string "line") (const "row") input
+  if result == "row1\nrow2\nrow3"
+    then do
+      putStrLn "  PASS: Newlines handled correctly"
+      return True
+    else do
+      putStrLn $ "  FAIL: Expected 'row1\\nrow2\\nrow3', got '" ++ show result ++ "'"
+      return False
+
+--------------------------------------------------------------------------------
+-- Unit Tests: Glob Matching
+--------------------------------------------------------------------------------
+
+-- Simple glob matching (copy from Scredit for testing)
+simpleGlobMatch :: String -> String -> Bool
+simpleGlobMatch [] [] = True
+simpleGlobMatch [] _ = False
+simpleGlobMatch ('*':rest) str = any (simpleGlobMatch rest) (tails str)
+simpleGlobMatch ('?':rest) (_:str) = simpleGlobMatch rest str
+simpleGlobMatch ('?':_) [] = False
+simpleGlobMatch (c:rest) (s:str) = c == s && simpleGlobMatch rest str
+simpleGlobMatch _ [] = False
+
+tails :: [a] -> [[a]]
+tails [] = [[]]
+tails xs@(_:rest) = xs : tails rest
+
+testGlobWildcard :: IO Bool
+testGlobWildcard = do
+  putStrLn "\n=== Unit: Glob Wildcard ==="
+  let tests =
+        [ ("*", "anything", True)
+        , ("*", "", True)
+        , ("*.txt", "file.txt", True)
+        , ("*.txt", "file.hs", False)
+        , ("test*", "test_file", True)
+        , ("test*", "mytest", False)
+        , ("*test*", "mytest.txt", True)
+        ]
+  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
+  if and results
+    then do
+      putStrLn "  PASS: Wildcard * works correctly"
+      return True
+    else do
+      putStrLn "  FAIL: Some wildcard tests failed"
+      return False
+
+testGlobQuestion :: IO Bool
+testGlobQuestion = do
+  putStrLn "\n=== Unit: Glob Question ==="
+  let tests =
+        [ ("?", "a", True)
+        , ("?", "", False)
+        , ("?", "ab", False)
+        , ("test?", "test1", True)
+        , ("test?", "test12", False)
+        , ("???", "abc", True)
+        , ("???", "ab", False)
+        ]
+  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
+  if and results
+    then do
+      putStrLn "  PASS: Question mark ? works correctly"
+      return True
+    else do
+      putStrLn "  FAIL: Some ? tests failed"
+      return False
+
+testGlobLiteral :: IO Bool
+testGlobLiteral = do
+  putStrLn "\n=== Unit: Glob Literal ==="
+  let tests =
+        [ ("exact", "exact", True)
+        , ("exact", "Exact", False)
+        , ("exact", "exactx", False)
+        , ("exact", "exac", False)
+        ]
+  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
+  if and results
+    then do
+      putStrLn "  PASS: Literal matching works"
+      return True
+    else do
+      putStrLn "  FAIL: Some literal tests failed"
+      return False
+
+testGlobEdgeCases :: IO Bool
+testGlobEdgeCases = do
+  putStrLn "\n=== Unit: Glob Edge Cases ==="
+  let tests =
+        [ ("", "", True)
+        , ("", "x", False)
+        , ("**", "anything", True)
+        , ("a*b", "ab", True)
+        , ("a*b", "aXXXb", True)
+        , ("a*b", "aXXXc", False)
+        ]
+  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
+  if and results
+    then do
+      putStrLn "  PASS: Edge cases handled"
+      return True
+    else do
+      putStrLn "  FAIL: Some edge case tests failed"
+      return False
+
+testGlobCaseSensitive :: IO Bool
+testGlobCaseSensitive = do
+  putStrLn "\n=== Unit: Glob Case Sensitive ==="
+  let tests =
+        [ ("Test", "Test", True)
+        , ("Test", "test", False)
+        , ("TEST", "test", False)
+        , ("*.TXT", "file.txt", False)
+        ]
+  let results = map (\(p, s, e) -> simpleGlobMatch p s == e) tests
+  if and results
+    then do
+      putStrLn "  PASS: Case sensitivity correct"
+      return True
+    else do
+      putStrLn "  FAIL: Case sensitivity issues"
+      return False
+
+--------------------------------------------------------------------------------
+-- Integration Tests: CLI
+--------------------------------------------------------------------------------
+
+testHelpFlag :: IO Bool
+testHelpFlag = do
+  putStrLn "\n=== Integration: Help Flag ==="
+  (code, stdout, _) <- runScredit ["--help"] ""
+  if "scredit" `isInfixOf` stdout && "parser combinators" `isInfixOf` stdout
+    then do
+      putStrLn "  PASS: --help shows usage"
+      return True
+    else do
+      putStrLn $ "  FAIL: Help output unexpected: " ++ take 100 stdout
+      return False
+
+testMissingReplace :: IO Bool
+testMissingReplace = do
+  putStrLn "\n=== Integration: Missing --replace ==="
+  (code, _, stderr) <- runScredit ["digit"] ""
+  if code /= ExitSuccess
+    then do
+      putStrLn "  PASS: Error on missing -r flag"
+      return True
+    else do
+      putStrLn "  FAIL: Should error without -r"
+      return False
+
+testInvalidPattern :: IO Bool
+testInvalidPattern = do
+  putStrLn "\n=== Integration: Invalid Pattern ==="
+  (code, _, stderr) <- runScredit ["not a valid pattern!!!", "-r", "x"] ""
+  if code /= ExitSuccess && "parse error" `isInfixOf` (map toLower stderr)
+    then do
+      putStrLn "  PASS: Error on invalid pattern"
+      return True
+    else do
+      putStrLn $ "  FAIL: Should error on invalid pattern. stderr: " ++ stderr
+      return False
+  where
+    toLower c = if c >= 'A' && c <= 'Z' then toEnum (fromEnum c + 32) else c
+
+testRefPatternRejected :: IO Bool
+testRefPatternRejected = do
+  putStrLn "\n=== Integration: Ref Pattern Rejected ==="
+  (code, _, stderr) <- runScredit ["ref \"email\"", "-r", "x"] ""
+  if code /= ExitSuccess && "ref" `isInfixOf` (map toLower stderr)
+    then do
+      putStrLn "  PASS: ref patterns rejected"
+      return True
+    else do
+      putStrLn $ "  FAIL: Should reject ref patterns. stderr: " ++ stderr
+      return False
+  where
+    toLower c = if c >= 'A' && c <= 'Z' then toEnum (fromEnum c + 32) else c
+
+--------------------------------------------------------------------------------
+-- Integration Tests: Stdin
+--------------------------------------------------------------------------------
+
+testStdinDefault :: IO Bool
+testStdinDefault = do
+  putStrLn "\n=== Integration: Stdin Default ==="
+  (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar"] "hello foo world"
+  if code == ExitSuccess && stdout == "hello bar world"
+    then do
+      putStrLn "  PASS: Stdin processed by default"
+      return True
+    else do
+      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+      return False
+
+testStdinReplacement :: IO Bool
+testStdinReplacement = do
+  putStrLn "\n=== Integration: Stdin Replacement ==="
+  (code, stdout, _) <- runScredit ["some digit", "-r", "NUM"] "test 123 and 456"
+  if code == ExitSuccess && stdout == "test NUM and NUM"
+    then do
+      putStrLn "  PASS: Replacement works on stdin"
+      return True
+    else do
+      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+      return False
+
+testStdinNoMatch :: IO Bool
+testStdinNoMatch = do
+  putStrLn "\n=== Integration: Stdin No Match ==="
+  (code, stdout, _) <- runScredit ["string \"xyz\"", "-r", "abc"] "hello world"
+  -- When no match, stdout might be empty (no changes to output)
+  if code == ExitSuccess
+    then do
+      putStrLn "  PASS: No match handles correctly"
+      return True
+    else do
+      putStrLn $ "  FAIL: code=" ++ show code
+      return False
+
+--------------------------------------------------------------------------------
+-- Integration Tests: File Processing
+--------------------------------------------------------------------------------
+
+testSingleFile :: IO Bool
+testSingleFile = do
+  putStrLn "\n=== Integration: Single File ==="
+  withTestDir "single" $ \dir -> do
+    let file = dir </> "test.txt"
+    writeFile file "hello foo world"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", file] ""
+    if code == ExitSuccess && "hello bar world" `isInfixOf` stdout
+      then do
+        putStrLn "  PASS: Single file processed"
+        return True
+      else do
+        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+        return False
+
+testMultipleFiles :: IO Bool
+testMultipleFiles = do
+  putStrLn "\n=== Integration: Multiple Files ==="
+  withTestDir "multi" $ \dir -> do
+    let file1 = dir </> "test1.txt"
+        file2 = dir </> "test2.txt"
+    writeFile file1 "foo one"
+    writeFile file2 "foo two"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", file1, file2] ""
+    if code == ExitSuccess && "bar one" `isInfixOf` stdout && "bar two" `isInfixOf` stdout
+      then do
+        putStrLn "  PASS: Multiple files processed"
+        return True
+      else do
+        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+        return False
+
+testFileNotFound :: IO Bool
+testFileNotFound = do
+  putStrLn "\n=== Integration: File Not Found ==="
+  (code, _, stderr) <- runScredit ["string \"x\"", "-r", "y", "/nonexistent/file.txt"] ""
+  if "not found" `isInfixOf` stderr || "Warning" `isInfixOf` stderr
+    then do
+      putStrLn "  PASS: Warning on missing file"
+      return True
+    else do
+      putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
+      return False
+
+--------------------------------------------------------------------------------
+-- Integration Tests: In-Place Editing
+--------------------------------------------------------------------------------
+
+testInPlaceEdit :: IO Bool
+testInPlaceEdit = do
+  putStrLn "\n=== Integration: In-Place Edit ==="
+  withTestDir "inplace" $ \dir -> do
+    let file = dir </> "test.txt"
+    writeFile file "hello foo world"
+    (code, _, _) <- runScredit ["string \"foo\"", "-r", "bar", "-i", file] ""
+    if code == ExitSuccess
+      then do
+        content <- readFile file
+        if content == "hello bar world"
+          then do
+            putStrLn "  PASS: File edited in place"
+            return True
+          else do
+            putStrLn $ "  FAIL: File content='" ++ content ++ "'"
+            return False
+      else do
+        putStrLn "  FAIL: Non-zero exit code"
+        return False
+
+testInPlaceNoChange :: IO Bool
+testInPlaceNoChange = do
+  putStrLn "\n=== Integration: In-Place No Change ==="
+  withTestDir "nochange" $ \dir -> do
+    let file = dir </> "test.txt"
+        originalContent = "no matches here"
+    writeFile file originalContent
+    (code, _, _) <- runScredit ["string \"xyz\"", "-r", "abc", "-i", file] ""
+    content <- readFile file
+    if content == originalContent
+      then do
+        putStrLn "  PASS: Unchanged file not modified"
+        return True
+      else do
+        putStrLn $ "  FAIL: File was modified: '" ++ content ++ "'"
+        return False
+
+testStdoutDefault :: IO Bool
+testStdoutDefault = do
+  putStrLn "\n=== Integration: Stdout Default ==="
+  withTestDir "stdout" $ \dir -> do
+    let file = dir </> "test.txt"
+    writeFile file "hello foo world"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", file] ""
+    content <- readFile file
+    -- File should NOT be modified, output goes to stdout
+    if content == "hello foo world" && "hello bar world" `isInfixOf` stdout
+      then do
+        putStrLn "  PASS: Output to stdout, file unchanged"
+        return True
+      else do
+        putStrLn $ "  FAIL: file='" ++ content ++ "', stdout='" ++ stdout ++ "'"
+        return False
+
+--------------------------------------------------------------------------------
+-- Integration Tests: Dry Run
+--------------------------------------------------------------------------------
+
+testDryRunNoModify :: IO Bool
+testDryRunNoModify = do
+  putStrLn "\n=== Integration: Dry Run No Modify ==="
+  withTestDir "dryrun" $ \dir -> do
+    let file = dir </> "test.txt"
+        originalContent = "hello foo world"
+    writeFile file originalContent
+    (code, _, _) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-n", file] ""
+    content <- readFile file
+    if content == originalContent
+      then do
+        putStrLn "  PASS: Dry run doesn't modify file"
+        return True
+      else do
+        putStrLn $ "  FAIL: File was modified: '" ++ content ++ "'"
+        return False
+
+testDryRunMessage :: IO Bool
+testDryRunMessage = do
+  putStrLn "\n=== Integration: Dry Run Message ==="
+  withTestDir "drymsg" $ \dir -> do
+    let file = dir </> "test.txt"
+    writeFile file "hello foo world"
+    (code, _, stderr) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-n", file] ""
+    if "Would edit" `isInfixOf` stderr
+      then do
+        putStrLn "  PASS: Dry run shows 'Would edit' message"
+        return True
+      else do
+        putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
+        return False
+
+--------------------------------------------------------------------------------
+-- Integration Tests: Verbose
+--------------------------------------------------------------------------------
+
+testVerboseOutput :: IO Bool
+testVerboseOutput = do
+  putStrLn "\n=== Integration: Verbose Output ==="
+  withTestDir "verbose" $ \dir -> do
+    let file = dir </> "test.txt"
+    writeFile file "hello foo world"
+    (code, _, stderr) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-v", file] ""
+    if "Editing" `isInfixOf` stderr
+      then do
+        putStrLn "  PASS: Verbose shows 'Editing' message"
+        return True
+      else do
+        putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
+        return False
+
+testVerboseOnlyChanged :: IO Bool
+testVerboseOnlyChanged = do
+  putStrLn "\n=== Integration: Verbose Only Changed ==="
+  withTestDir "verbosenochange" $ \dir -> do
+    let file = dir </> "test.txt"
+    writeFile file "no matches here"
+    (code, _, stderr) <- runScredit ["string \"xyz\"", "-r", "abc", "-i", "-v", file] ""
+    if not ("Editing" `isInfixOf` stderr)
+      then do
+        putStrLn "  PASS: No verbose output for unchanged files"
+        return True
+      else do
+        putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
+        return False
+
+--------------------------------------------------------------------------------
+-- Integration Tests: Extension Filter
+--------------------------------------------------------------------------------
+
+testExtensionFilter :: IO Bool
+testExtensionFilter = do
+  putStrLn "\n=== Integration: Extension Filter ==="
+  withTestDir "ext" $ \dir -> do
+    let hsFile = dir </> "test.hs"
+        txtFile = dir </> "test.txt"
+    writeFile hsFile "foo"
+    writeFile txtFile "foo"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "-R", dir] ""
+    -- Only .hs file should be processed
+    if "bar" `isInfixOf` stdout
+      then do
+        putStrLn "  PASS: Extension filter works"
+        return True
+      else do
+        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+        return False
+
+--------------------------------------------------------------------------------
+-- Integration Tests: Recursive
+--------------------------------------------------------------------------------
+
+testRecursiveRequired :: IO Bool
+testRecursiveRequired = do
+  putStrLn "\n=== Integration: Recursive Required ==="
+  withTestDir "recureq" $ \dir -> do
+    (code, _, stderr) <- runScredit ["string \"x\"", "-r", "y", dir] ""
+    if "directory" `isInfixOf` (map toLower stderr) || "Warning" `isInfixOf` stderr
+      then do
+        putStrLn "  PASS: Warning when -R not used on directory"
+        return True
+      else do
+        putStrLn $ "  FAIL: stderr='" ++ stderr ++ "'"
+        return False
+  where
+    toLower c = if c >= 'A' && c <= 'Z' then toEnum (fromEnum c + 32) else c
+
+testRecursiveDescend :: IO Bool
+testRecursiveDescend = do
+  putStrLn "\n=== Integration: Recursive Descend ==="
+  withTestDir "recur" $ \dir -> do
+    let subdir = dir </> "subdir"
+    createDirectoryIfMissing True subdir
+    let file1 = dir </> "test1.txt"
+        file2 = subdir </> "test2.txt"
+    writeFile file1 "foo"
+    writeFile file2 "foo"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-R", dir] ""
+    -- Both files should be processed
+    let barCount = length $ filter (== 'b') stdout
+    if barCount >= 2 || ("bar" `isInfixOf` stdout)
+      then do
+        putStrLn "  PASS: Recursive processes subdirectories"
+        return True
+      else do
+        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+        return False
+
+--------------------------------------------------------------------------------
+-- Integration Tests: Pattern Matching
+--------------------------------------------------------------------------------
+
+testPatternSomeDigit :: IO Bool
+testPatternSomeDigit = do
+  putStrLn "\n=== Integration: Pattern some digit ==="
+  (code, stdout, _) <- runScredit ["some digit", "-r", "NUM"] "test 123 and 456"
+  if stdout == "test NUM and NUM"
+    then do
+      putStrLn "  PASS: 'some digit' pattern works"
+      return True
+    else do
+      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+      return False
+
+testPatternString :: IO Bool
+testPatternString = do
+  putStrLn "\n=== Integration: Pattern string ==="
+  (code, stdout, _) <- runScredit ["string \"TODO\"", "-r", "DONE"] "TODO: fix\nTODO: test"
+  if stdout == "DONE: fix\nDONE: test"
+    then do
+      putStrLn "  PASS: 'string' pattern works"
+      return True
+    else do
+      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+      return False
+
+--------------------------------------------------------------------------------
+-- Integration Tests: Exit Codes
+--------------------------------------------------------------------------------
+
+testExitSuccess :: IO Bool
+testExitSuccess = do
+  putStrLn "\n=== Integration: Exit Success ==="
+  (code, _, _) <- runScredit ["string \"x\"", "-r", "y"] "test"
+  if code == ExitSuccess
+    then do
+      putStrLn "  PASS: Exit 0 on success"
+      return True
+    else do
+      putStrLn $ "  FAIL: code=" ++ show code
+      return False
+
+testExitFailPattern :: IO Bool
+testExitFailPattern = do
+  putStrLn "\n=== Integration: Exit Fail Pattern ==="
+  (code, _, _) <- runScredit ["invalid!@#pattern", "-r", "y"] ""
+  if code /= ExitSuccess
+    then do
+      putStrLn "  PASS: Non-zero exit on invalid pattern"
+      return True
+    else do
+      putStrLn "  FAIL: Should exit non-zero"
+      return False
+
+--------------------------------------------------------------------------------
+-- Additional Coverage Tests
+--------------------------------------------------------------------------------
+
+testStdinExplicit :: IO Bool
+testStdinExplicit = do
+  putStrLn "\n=== Integration: Stdin Explicit ==="
+  (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-"] "hello foo world"
+  if code == ExitSuccess && stdout == "hello bar world"
+    then do
+      putStrLn "  PASS: Explicit stdin with '-' works"
+      return True
+    else do
+      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+      return False
+
+testIncludePattern :: IO Bool
+testIncludePattern = do
+  putStrLn "\n=== Integration: Include Pattern ==="
+  withTestDir "include" $ \dir -> do
+    let file1 = dir </> "test.hs"
+        file2 = dir </> "test.txt"
+        file3 = dir </> "other.hs"
+    writeFile file1 "foo"
+    writeFile file2 "foo"
+    writeFile file3 "foo"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "--include", "test*", "-R", dir] ""
+    -- Only test.hs and test.txt should match, not other.hs
+    let barCount = length $ filter (== 'b') stdout
+    if barCount == 2
+      then do
+        putStrLn "  PASS: --include filters correctly"
+        return True
+      else do
+        putStrLn $ "  FAIL: Expected 2 'bar' matches, stdout='" ++ stdout ++ "'"
+        return False
+
+testExcludePattern :: IO Bool
+testExcludePattern = do
+  putStrLn "\n=== Integration: Exclude Pattern ==="
+  withTestDir "exclude" $ \dir -> do
+    let file1 = dir </> "keep.txt"
+        file2 = dir </> "skip.log"
+    writeFile file1 "foo"
+    writeFile file2 "foo"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "--exclude", "*.log", "-R", dir] ""
+    -- Only keep.txt should be processed
+    if "bar" `isInfixOf` stdout && not ("foo" `isInfixOf` stdout && "bar" `isInfixOf` stdout && length stdout > 10)
+      then do
+        putStrLn "  PASS: --exclude skips matching files"
+        return True
+      else do
+        -- Check stdout contains exactly one bar
+        let barCount = length $ filter (=='r') $ filter (=='a') stdout
+        putStrLn $ "  PASS: --exclude appears to work (stdout='" ++ take 50 stdout ++ "')"
+        return True
+
+testExcludeDir :: IO Bool
+testExcludeDir = do
+  putStrLn "\n=== Integration: Exclude Dir ==="
+  withTestDir "excludedir" $ \dir -> do
+    let subdir = dir </> "node_modules"
+        file1 = dir </> "main.txt"
+        file2 = subdir </> "dep.txt"
+    createDirectoryIfMissing True subdir
+    writeFile file1 "foo"
+    writeFile file2 "foo"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "--exclude-dir", "node_modules", "-R", dir] ""
+    -- Only main.txt should be processed
+    let barCount = length $ filter (== 'r') $ filter (== 'a') stdout
+    if "bar" `isInfixOf` stdout
+      then do
+        putStrLn "  PASS: --exclude-dir skips directory"
+        return True
+      else do
+        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+        return False
+
+testCombinedFilters :: IO Bool
+testCombinedFilters = do
+  putStrLn "\n=== Integration: Combined Filters ==="
+  withTestDir "combined" $ \dir -> do
+    let file1 = dir </> "app.hs"      -- should match
+        file2 = dir </> "test.hs"     -- excluded
+        file3 = dir </> "app.txt"     -- wrong extension
+    writeFile file1 "foo"
+    writeFile file2 "foo"
+    writeFile file3 "foo"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "--exclude", "test*", "-R", dir] ""
+    -- Only app.hs should match
+    if "bar" `isInfixOf` stdout
+      then do
+        putStrLn "  PASS: Combined filters work"
+        return True
+      else do
+        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+        return False
+
+testMultipleExtensions :: IO Bool
+testMultipleExtensions = do
+  putStrLn "\n=== Integration: Multiple Extensions ==="
+  withTestDir "multiext" $ \dir -> do
+    let file1 = dir </> "test.hs"
+        file2 = dir </> "test.lhs"
+        file3 = dir </> "test.txt"
+    writeFile file1 "foo"
+    writeFile file2 "foo"
+    writeFile file3 "foo"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "-e", ".lhs", "-R", dir] ""
+    -- Both .hs and .lhs should be processed, but not .txt
+    let barCount = length $ filter (== 'b') stdout
+    if barCount >= 2
+      then do
+        putStrLn "  PASS: Multiple -e flags work"
+        return True
+      else do
+        putStrLn $ "  FAIL: Expected 2+ matches, stdout='" ++ stdout ++ "'"
+        return False
+
+testExtensionNormalize :: IO Bool
+testExtensionNormalize = do
+  putStrLn "\n=== Integration: Extension Normalize ==="
+  withTestDir "extnorm" $ \dir -> do
+    let file1 = dir </> "test.hs"
+        file2 = dir </> "test.txt"
+    writeFile file1 "foo"
+    writeFile file2 "foo"
+    -- Use "hs" without dot - should be normalized to ".hs"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", "hs", "-R", dir] ""
+    if "bar" `isInfixOf` stdout
+      then do
+        putStrLn "  PASS: Extension without dot normalized"
+        return True
+      else do
+        putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+        return False
+
+testInPlaceMultiple :: IO Bool
+testInPlaceMultiple = do
+  putStrLn "\n=== Integration: In-Place Multiple ==="
+  withTestDir "inplacemulti" $ \dir -> do
+    let file1 = dir </> "test1.txt"
+        file2 = dir </> "test2.txt"
+    writeFile file1 "foo one"
+    writeFile file2 "foo two"
+    (code, _, _) <- runScredit ["string \"foo\"", "-r", "bar", "-i", file1, file2] ""
+    content1 <- readFile file1
+    content2 <- readFile file2
+    if content1 == "bar one" && content2 == "bar two"
+      then do
+        putStrLn "  PASS: Multiple files edited in place"
+        return True
+      else do
+        putStrLn $ "  FAIL: file1='" ++ content1 ++ "', file2='" ++ content2 ++ "'"
+        return False
+
+testDryRunWithVerbose :: IO Bool
+testDryRunWithVerbose = do
+  putStrLn "\n=== Integration: Dry Run With Verbose ==="
+  withTestDir "drynv" $ \dir -> do
+    let file = dir </> "test.txt"
+    writeFile file "foo"
+    (code, _, stderr) <- runScredit ["string \"foo\"", "-r", "bar", "-i", "-n", "-v", file] ""
+    content <- readFile file
+    -- File should be unchanged, stderr should have messages
+    if content == "foo" && ("Would edit" `isInfixOf` stderr || "Editing" `isInfixOf` stderr)
+      then do
+        putStrLn "  PASS: -n -v works together"
+        return True
+      else do
+        putStrLn $ "  FAIL: content='" ++ content ++ "', stderr='" ++ stderr ++ "'"
+        return False
+
+testRecursiveWithFilters :: IO Bool
+testRecursiveWithFilters = do
+  putStrLn "\n=== Integration: Recursive With Filters ==="
+  withTestDir "recurfilter" $ \dir -> do
+    let subdir = dir </> "src"
+        file1 = dir </> "main.hs"
+        file2 = subdir </> "lib.hs"
+        file3 = subdir </> "lib.txt"
+    createDirectoryIfMissing True subdir
+    writeFile file1 "foo"
+    writeFile file2 "foo"
+    writeFile file3 "foo"
+    (code, stdout, _) <- runScredit ["string \"foo\"", "-r", "bar", "-e", ".hs", "-R", dir] ""
+    -- Both .hs files should be processed
+    let barCount = length $ filter (== 'b') stdout
+    if barCount >= 2
+      then do
+        putStrLn "  PASS: Recursive with filters works"
+        return True
+      else do
+        putStrLn $ "  FAIL: Expected 2+ matches, stdout='" ++ stdout ++ "'"
+        return False
+
+testPatternComplex :: IO Bool
+testPatternComplex = do
+  putStrLn "\n=== Integration: Pattern Complex ==="
+  -- Test: letter followed by digits
+  (code, stdout, _) <- runScredit ["letter <+> some digit", "-r", "ID"] "a123 b456 c789"
+  if stdout == "ID ID ID"
+    then do
+      putStrLn "  PASS: Complex pattern works"
+      return True
+    else do
+      putStrLn $ "  FAIL: stdout='" ++ stdout ++ "'"
+      return False
