diff --git a/app/main.hs b/app/main.hs
new file mode 100644
--- /dev/null
+++ b/app/main.hs
@@ -0,0 +1,91 @@
+import Chez.Grater.Internal.Prelude
+
+import Chez.Grater (scrapeAndParseUrl, scrapeUrl)
+import Chez.Grater.Manager (createManager)
+import Chez.Grater.Parser (mkIngredients)
+import Chez.Grater.Readable.Types (mkReadableIngredient, showReadableIngredient)
+import Chez.Grater.Scraper.Site (allScrapers)
+import Chez.Grater.Scraper.Types (ScrapedIngredient(..), ScrapedRecipeName(..), ScrapedStep(..))
+import Chez.Grater.Types (Ingredient(..), RecipeName(..), Step(..))
+import Data.Aeson ((.=), object)
+import Data.Aeson.Encode.Pretty (encodePretty)
+import Network.URI (parseURI)
+import Options.Applicative ((<**>))
+import qualified Data.ByteString.Lazy.Char8 as LC8
+import qualified Data.Text as Text
+import qualified Options.Applicative as Opt
+
+data Output = OutputText | OutputJson
+  deriving (Eq)
+
+instance Show Output where
+  show = \case
+    OutputText -> "text"
+    OutputJson -> "json"
+
+data Opts = Opts
+  { optsUrl :: String
+  , optsNoParse :: Bool
+  , optsOutput :: Output
+  }
+
+parseArgs :: IO Opts
+parseArgs = Opt.execParser (Opt.info (parser <**> Opt.helper) (Opt.progDesc "Scrape a recipe from a URL"))
+  where
+    parser = Opts
+      <$> Opt.strArgument
+        ( Opt.metavar "URL"
+        )
+      <*> Opt.switch
+        ( Opt.long "no-parse"
+            <> Opt.help "Don't parse the recipe"
+        )
+      <*> Opt.option readOutput
+        ( Opt.short 'o'
+            <> Opt.long "output"
+            <> Opt.value OutputText
+            <> Opt.showDefault
+            <> Opt.help "Output format for the recipe (text or json)"
+        )
+    readOutput = Opt.maybeReader $ \case
+      "text" -> Just OutputText
+      "json" -> Just OutputJson
+      _ -> Nothing
+
+showRecipe :: RecipeName -> [Ingredient] -> [Step] -> IO ()
+showRecipe (RecipeName name) ingredients steps = do
+  let width = length (Text.unpack name)
+  putStrLn $ Text.unpack name
+  putStrLn $ replicate width '-'
+  traverse_ putStrLn $ fmap (Text.unpack . showReadableIngredient . mkReadableIngredient) ingredients
+  putStrLn $ replicate width '-'
+  traverse_ putStrLn $ fmap (("- " <>) . Text.unpack . unStep) steps
+
+jsonRecipe :: RecipeName -> [Ingredient] -> [Step] -> IO ()
+jsonRecipe name ingredients steps =
+  putStrLn . LC8.unpack . encodePretty . object $
+    [ "name" .= name
+    , "ingredients" .= fmap mkReadableIngredient ingredients
+    , "steps" .= fmap unStep steps
+    ]
+
+renderRecipe :: Output -> RecipeName -> [Ingredient] -> [Step] -> IO ()
+renderRecipe = \case
+  OutputText -> showRecipe
+  OutputJson -> jsonRecipe
+
+main :: IO ()
+main = do
+  Opts {..} <- parseArgs
+  manager <- createManager
+  url <- maybe (fail "Invalid URL") pure $ parseURI optsUrl
+  case optsNoParse of
+    True -> do
+      (name, ingredients, steps, _) <- scrapeUrl allScrapers manager url
+      renderRecipe optsOutput
+        (RecipeName . unScrapedRecipeName $ name)
+        (concatMap (mkIngredients . unScrapedIngredient) ingredients)
+        (fmap (Step . unScrapedStep) steps)
+    False -> do
+      (name, ingredients, steps, _) <- scrapeAndParseUrl allScrapers manager url
+      renderRecipe optsOutput name ingredients steps
diff --git a/chez-grater.cabal b/chez-grater.cabal
--- a/chez-grater.cabal
+++ b/chez-grater.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name:          chez-grater
-version:       0.1.0
+version:       0.1.1
 author:        Dan Fithian
 maintainer:    Dan Fithian
 copyright:     2022 Dan Fithian
@@ -55,6 +55,9 @@
 common ghc-options
   ghc-options: -Wall -Wunused-packages -fwarn-tabs -fwarn-redundant-constraints -Wincomplete-uni-patterns -eventlog
 
+common ghc-exe-options
+  ghc-options: -Wall -Wunused-packages -fwarn-tabs -fwarn-redundant-constraints -Wincomplete-uni-patterns -eventlog -O2
+
 library
   import: options, ghc-options
   exposed-modules:
@@ -94,6 +97,23 @@
     , text < 1.3
     , unordered-containers < 0.3
 
+executable chez-grater
+  import: options, ghc-exe-options
+  main-is: main.hs
+  other-modules:
+      Paths_chez_grater
+  hs-source-dirs:
+      app
+  build-depends:
+      aeson < 1.6
+    , aeson-pretty < 0.9
+    , base < 5.0
+    , bytestring < 0.12
+    , network-uri < 2.7
+    , optparse-applicative < 0.18
+    , text < 1.3
+    , chez-grater
+
 test-suite tests
   import: options, ghc-options
   type: exitcode-stdio-1.0
@@ -108,7 +128,7 @@
   build-depends:
       attoparsec < 0.14
     , base < 5.0
-    , bytestring < 0.11
+    , bytestring < 0.12
     , case-insensitive < 1.3
     , containers < 0.7
     , file-embed < 0.1
diff --git a/src/Chez/Grater/Internal/Prelude.hs b/src/Chez/Grater/Internal/Prelude.hs
--- a/src/Chez/Grater/Internal/Prelude.hs
+++ b/src/Chez/Grater/Internal/Prelude.hs
@@ -5,6 +5,7 @@
   , module Control.Exception
   , module Control.Monad
   , module Data.CaseInsensitive
+  , module Data.Foldable
   , module Data.HashMap.Strict
   , module Data.List
   , module Data.Map.Strict
@@ -21,6 +22,7 @@
 import Control.Monad (replicateM, void)
 import Data.CaseInsensitive (CI)
 import Data.Containers.ListUtils (nubOrd)
+import Data.Foldable (toList, traverse_)
 import Data.HashMap.Strict (HashMap)
 import Data.List (find, groupBy, sortBy, sortOn)
 import Data.Map.Strict (Map)
diff --git a/src/Chez/Grater/Readable/Types.hs b/src/Chez/Grater/Readable/Types.hs
--- a/src/Chez/Grater/Readable/Types.hs
+++ b/src/Chez/Grater/Readable/Types.hs
@@ -5,9 +5,10 @@
 
 import Chez.Grater.Internal.CI.Orphans ()
 import Chez.Grater.Internal.Json (jsonOptions)
-import Chez.Grater.Types (Quantity(..), Unit(..))
+import Chez.Grater.Types (Ingredient(..), IngredientName(..), Quantity(..), Unit(..))
 import Data.Aeson (FromJSON, ToJSON)
 import Data.Aeson.TH (deriveJSON)
+import qualified Data.CaseInsensitive as CI
 
 data ReadableFraction = ReadableFraction
   { readableFractionNumerator   :: Int
@@ -24,8 +25,16 @@
 newtype ReadableUnit = ReadableUnit { unReadableUnit :: CI Text }
   deriving (Eq, Ord, Show, FromJSON, ToJSON)
 
+data ReadableIngredient = ReadableIngredient
+  { readableIngredientName     :: IngredientName
+  , readableIngredientQuantity :: ReadableQuantity
+  , readableIngredientUnit     :: Maybe ReadableUnit
+  }
+  deriving (Eq, Ord, Show)
+
 deriveJSON (jsonOptions "readableFraction") ''ReadableFraction
 deriveJSON (jsonOptions "readableQuantity") ''ReadableQuantity
+deriveJSON (jsonOptions "readableIngredient") ''ReadableIngredient
 
 mkReadableQuantity :: Quantity -> ReadableQuantity
 mkReadableQuantity q = case splitQuantity q of
@@ -65,7 +74,36 @@
           True -> Just (round q2, 0.0)
           False -> let w = truncate q2 in Just (w, q2 - fromIntegral w)
 
+showReadableQuantity :: ReadableQuantity -> Maybe Text
+showReadableQuantity ReadableQuantity {..} =
+  case (readableQuantityWhole, readableQuantityFraction) of
+    (Nothing, Nothing) -> Nothing
+    (Just w, Nothing) -> Just $ tshow w
+    (Nothing, Just ReadableFraction {..}) -> Just $ tshow readableFractionNumerator <> "/" <> tshow readableFractionDenominator
+    (Just w, Just ReadableFraction {..}) -> Just $ tshow w <> " " <> tshow readableFractionNumerator <> "/" <> tshow readableFractionDenominator
+
 mkReadableUnit :: Unit -> Maybe ReadableUnit
 mkReadableUnit = \case
   Unit x -> Just (ReadableUnit x)
   UnitMissing -> Nothing
+
+showReadableUnit :: ReadableUnit -> Text
+showReadableUnit = CI.original . unReadableUnit
+
+mkReadableIngredient :: Ingredient -> ReadableIngredient
+mkReadableIngredient Ingredient {..} =
+  ReadableIngredient
+    { readableIngredientName = ingredientName
+    , readableIngredientQuantity = mkReadableQuantity ingredientQuantity
+    , readableIngredientUnit = mkReadableUnit ingredientUnit
+    }
+
+showReadableIngredient :: ReadableIngredient -> Text
+showReadableIngredient ReadableIngredient {..} =
+  case (showReadableQuantity readableIngredientQuantity, showReadableUnit <$> readableIngredientUnit) of
+    (Nothing, Nothing) -> showIngredientName readableIngredientName
+    (Just q, Nothing) -> q <> " " <> showIngredientName readableIngredientName
+    (Nothing, Just u) -> u <> " " <> showIngredientName readableIngredientName
+    (Just q, Just u) -> q <> " " <> u <> " " <> showIngredientName readableIngredientName
+  where
+    showIngredientName = CI.original . unIngredientName
diff --git a/src/Chez/Grater/Scraper/Types.hs b/src/Chez/Grater/Scraper/Types.hs
--- a/src/Chez/Grater/Scraper/Types.hs
+++ b/src/Chez/Grater/Scraper/Types.hs
@@ -35,10 +35,10 @@
   deriving (Eq, Show, Generic)
 
 -- |Unparsed ingredient.
-data ScrapedIngredient = ScrapedIngredient Text
+newtype ScrapedIngredient = ScrapedIngredient { unScrapedIngredient :: Text }
   deriving (Eq, Ord, Show)
 
-data ScrapedStep = ScrapedStep Text
+newtype ScrapedStep = ScrapedStep { unScrapedStep :: Text }
   deriving (Eq, Ord, Show)
 
 data IngredientScraper = IngredientScraper
