diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+# Revision history for ebird-cli
+
+## 0.1.0.0 -- 2023-07-30
+
+* First version. Released on an unsuspecting world.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2023 Finley McIlwaine
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/Main.hs b/Main.hs
new file mode 100644
--- /dev/null
+++ b/Main.hs
@@ -0,0 +1,16 @@
+module Main where
+
+-- |
+-- Module      : Main
+-- Copyright   : (c) 2023 Finley McIlwaine
+-- License     : MIT (see LICENSE)
+--
+-- Maintainer  : Finley McIlwaine <finleymcilwaine@gmail.com>
+--
+-- An executable command-line utility for interacting with the
+-- [eBird API](https://documenter.getpostman.com/view/664302/S1ENwy59).
+
+import Data.EBird.CLI (eBirdCli)
+
+main :: IO ()
+main = eBirdCli
diff --git a/ebird-cli.cabal b/ebird-cli.cabal
new file mode 100644
--- /dev/null
+++ b/ebird-cli.cabal
@@ -0,0 +1,62 @@
+cabal-version:      3.0
+name:               ebird-cli
+version:            0.1.0.0
+synopsis:
+    A command-line utility for interacting with the
+    eBird API.
+description:
+    A library containing the functions used to implement a command-line utility
+    for interacting with the
+    [eBird API](https://documenter.getpostman.com/view/664302/S1ENwy59).
+license:            MIT
+license-file:       LICENSE
+author:             Finley McIlwaine
+maintainer:         finleymcilwaine@gmail.com
+copyright:          2023 Finley McIlwaine
+category:           Web
+build-type:         Simple
+extra-doc-files:    CHANGELOG.md
+bug-reports:        https://github.com/FinleyMcIlwaine/ebird-haskell/issues
+homepage:           https://github.com/FinleyMcIlwaine/ebird-haskell
+
+tested-with:
+    GHC == 8.10.7
+  , GHC == 9.2.7
+  , GHC == 9.4.5
+  , GHC == 9.6.2
+
+common common
+    build-depends:
+        base >= 4.13.3.0 && < 4.19
+    default-extensions:
+      ImportQualifiedPost
+      LambdaCase
+      OverloadedStrings
+      RecordWildCards
+    default-language: Haskell2010
+
+executable ebird
+    import: common
+    main-is:
+        Main.hs
+    build-depends:
+        ebird-cli
+
+library
+    import: common
+    exposed-modules:
+        Data.EBird.CLI
+    build-depends:
+        ebird-api    >= 0.1.0.0 && < 0.2
+      , ebird-client >= 0.1.0.0 && < 0.2
+
+      , aeson                >= 1.5.6.0   && < 2.2
+      , aeson-pretty         >= 0.8.8     && < 0.9
+      , attoparsec           >= 0.14.1    && < 0.15
+      , bytestring           >= 0.10.12.0 && < 0.12
+      , directory            >= 1.3.6.0   && < 1.4
+      , filepath             >= 1.4.2.1   && < 1.5
+      , optparse-applicative >= 0.16.1.0  && < 0.19
+      , text                 >= 1.2.4.1   && < 2.1
+    hs-source-dirs:
+        src
diff --git a/src/Data/EBird/CLI.hs b/src/Data/EBird/CLI.hs
new file mode 100644
--- /dev/null
+++ b/src/Data/EBird/CLI.hs
@@ -0,0 +1,1621 @@
+{-# LANGUAGE CPP                 #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE DataKinds           #-}
+
+-- |
+-- Module      : Data.EBird.CLI
+-- Copyright   : (c) 2023 Finley McIlwaine
+-- License     : MIT (see LICENSE)
+--
+-- Maintainer  : Finley McIlwaine <finleymcilwaine@gmail.com>
+--
+-- Functions used to implement a command-line utility for interacting with the
+-- [eBird API](https://documenter.getpostman.com/view/664302/S1ENwy59).
+
+module Data.EBird.CLI where
+
+import Control.Exception
+import Data.Aeson
+import Data.Aeson.Encode.Pretty
+import Data.Attoparsec.Text qualified as A
+#if !MIN_VERSION_bytestring(0,11,0)
+-- Data.ByteString.Char8 does not export 'toStrict' until 0.11.0.0
+import Data.ByteString.Lazy qualified as BS (toStrict)
+#endif
+import Data.ByteString.Char8 qualified as BS
+import Data.Char
+import Data.Text (Text)
+import Data.Text qualified as Text
+import Data.Text.IO qualified as Text
+import Options.Applicative
+import System.Directory
+import System.Environment
+import System.Exit
+import System.FilePath
+import Text.Printf
+
+import Data.EBird.API
+import Data.EBird.Client
+
+-- | Entry point for the @ebird@ CLI. Parses the command arguments, selects an
+-- API key, and executes the command.
+--
+-- The API key may be provided as a command-line option. If the key option is
+-- not provided, it is read from the file @~\/.ebird\/key.txt@. If that file is
+-- unavailable for reading, and no key option is provided, the application
+-- exits (if the command requires a key).
+eBirdCli :: IO ()
+eBirdCli = do
+    (optKey, c) <- execParser opts
+    fileKey <- readEBirdAPIKey
+    let getKey =
+          case optKey <|> fileKey of
+            Just k -> pure k
+            Nothing -> do
+              eBirdFail
+                ( "An API key is required for this command, but no API key\n" <>
+                  "was provided via the `-k` option and no API key file was\n" <>
+                  "found at ~/.ebird/key.txt. Exiting."
+                )
+    runEBirdCommand getKey c
+  where
+    opts :: ParserInfo (Maybe Text, EBirdCommand)
+    opts =
+      info
+        (eBirdCommand <**> helper)
+        (    header "ebird - Go birding on your command line!"
+          <> progDesc "Query the official eBird API"
+        )
+
+-- | Read an eBird API key from @~\/.ebird\/key.txt@. If the file exists and is
+-- available for reading, the result is 'Just' the contents of the file,
+-- stripped of leading/trailing whitespace. Otherwise, the result is 'Nothing'.
+readEBirdAPIKey :: IO (Maybe Text)
+readEBirdAPIKey = do
+    home <- getHomeDirectory
+    catch
+      ( Just . Text.strip <$>
+          Text.readFile (home </> ".ebird" </> "key" <.> "txt")
+      )
+      ( \(_ :: IOException) -> pure Nothing
+      )
+
+-- | Run an 'EBirdCommand' with a given API key.
+runEBirdCommand
+  :: IO Text
+  -- ^ Get an API key (this may fail if not found or provided)
+  -> EBirdCommand
+  -- ^ Command to execute
+  -> IO ()
+runEBirdCommand getAPIKey = \case
+    RecentObservationsCommand RecentObservationsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        recentObservations_ apiKey
+          recentObservationsRegion
+          recentObservationsBack
+          recentObservationsCategories
+          recentObservationsHotspots
+          recentObservationsProvisionals
+          recentObservationsMaxResults
+          recentObservationsSubRegions
+          recentObservationsSPPLocale
+      handleResponse res
+
+    RecentNotableObservationsCommand RecentNotableObservationsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        recentNotableObservations_ apiKey
+          recentNotableObservationsRegion
+          recentNotableObservationsBack
+          recentNotableObservationsDetail
+          recentNotableObservationsHotspots
+          recentNotableObservationsMaxResults
+          recentNotableObservationsSubRegions
+          recentNotableObservationsSPPLocale
+      handleResponse res
+
+    RecentSpeciesObservationsCommand RecentSpeciesObservationsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        recentSpeciesObservations_ apiKey
+          recentSpeciesObservationsRegion
+          recentSpeciesObservationsSpecies
+          recentSpeciesObservationsBack
+          recentSpeciesObservationsHotspots
+          recentSpeciesObservationsProvisionals
+          recentSpeciesObservationsMaxResults
+          recentSpeciesObservationsSubRegions
+          recentSpeciesObservationsSPPLocale
+      handleResponse res
+
+    RecentNearbyObservationsCommand RecentNearbyObservationsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        recentNearbyObservations_ apiKey
+          recentNearbyObservationsLatitude
+          recentNearbyObservationsLongitude
+          recentNearbyObservationsDist
+          recentNearbyObservationsBack
+          recentNearbyObservationsCategories
+          recentNearbyObservationsHotspots
+          recentNearbyObservationsProvisionals
+          recentNearbyObservationsMaxResults
+          recentNearbyObservationsSortBy
+          recentNearbyObservationsSPPLocale
+      handleResponse res
+
+    RecentNearbySpeciesObservationsCommand RecentNearbySpeciesObservationsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        recentNearbySpeciesObservations_ apiKey
+          recentNearbySpeciesObservationsSpecies
+          recentNearbySpeciesObservationsLatitude
+          recentNearbySpeciesObservationsLongitude
+          recentNearbySpeciesObservationsDist
+          recentNearbySpeciesObservationsBack
+          recentNearbySpeciesObservationsCategories
+          recentNearbySpeciesObservationsHotspots
+          recentNearbySpeciesObservationsProvisionals
+          recentNearbySpeciesObservationsMaxResults
+          recentNearbySpeciesObservationsSortBy
+          recentNearbySpeciesObservationsSPPLocale
+      handleResponse res
+
+    RecentNearestSpeciesObservationsCommand RecentNearestSpeciesObservationsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        recentNearestSpeciesObservations_ apiKey
+          recentNearestSpeciesObservationsSpecies
+          recentNearestSpeciesObservationsLatitude
+          recentNearestSpeciesObservationsLongitude
+          recentNearestSpeciesObservationsDist
+          recentNearestSpeciesObservationsBack
+          recentNearestSpeciesObservationsHotspots
+          recentNearestSpeciesObservationsProvisionals
+          recentNearestSpeciesObservationsMaxResults
+          recentNearestSpeciesObservationsSPPLocale
+      handleResponse res
+
+    RecentNearbyNotableObservationsCommand RecentNearbyNotableObservationsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        recentNearbyNotableObservations_ apiKey
+          recentNearbyNotableObservationsLatitude
+          recentNearbyNotableObservationsLongitude
+          recentNearbyNotableObservationsDist
+          recentNearbyNotableObservationsDetail
+          recentNearbyNotableObservationsBack
+          recentNearbyNotableObservationsHotspots
+          recentNearbyNotableObservationsMaxResults
+          recentNearbyNotableObservationsSPPLocale
+      handleResponse res
+
+    HistoricalObservationsCommand HistoricalObservationsOptions{..} -> do
+      apiKey <- getAPIKey
+      let (y,m,d) = eBirdDateToGregorian historicalObservationsDate
+      res <- askEBird $
+        historicalObservations_ apiKey
+          historicalObservationsRegion
+          y m d
+          historicalObservationsCategories
+          historicalObservationsDetail
+          historicalObservationsHotspots
+          historicalObservationsProvisionals
+          historicalObservationsMaxResults
+          historicalObservationsRank
+          historicalObservationsSubRegions
+          historicalObservationsSPPLocale
+      handleResponse res
+
+    RecentChecklistsCommand RecentChecklistsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        recentChecklists_ apiKey
+          recentChecklistsRegion
+          recentChecklistsMaxResults
+      handleResponse res
+
+    Top100Command Top100Options{..} -> do
+      apiKey <- getAPIKey
+      let (y,m,d) = eBirdDateToGregorian top100Date
+      res <- askEBird $
+        top100_ apiKey
+          top100Region
+          y m d
+          top100RankedBy
+          top100MaxResults
+      handleResponse res
+
+    ChecklistFeedCommand ChecklistFeedOptions{..} -> do
+      apiKey <- getAPIKey
+      let (y,m,d) = eBirdDateToGregorian checklistFeedDate
+      res <- askEBird $
+        checklistFeed_ apiKey
+          checklistFeedRegion
+          y m d
+          checklistFeedSortBy
+          checklistFeedMaxResults
+      handleResponse res
+
+    RegionalStatisticsCommand RegionalStatisticsOptions{..} -> do
+      apiKey <- getAPIKey
+      let (y,m,d) = eBirdDateToGregorian regionalStatisticsDate
+      res <- askEBird $
+        regionalStatistics_ apiKey
+          regionalStatisticsRegion
+          y m d
+      handleResponse res
+
+    SpeciesListCommand SpeciesListOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        speciesList_ apiKey speciesListRegion
+      handleResponse res
+
+    ViewChecklistCommand ViewChecklistOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        viewChecklist_ apiKey viewChecklistSubId
+      handleResponse res
+
+    RegionHotspotsCommand RegionHotspotsOptions{..} -> do
+      res <- askEBird $
+        regionHotspots_
+          regionHotspotsRegion
+          regionHotspotsBack
+          regionHotspotsFmt
+      handleResponse res
+
+    NearbyHotspotsCommand NearbyHotspotsOptions{..} -> do
+      res <- askEBird $
+        nearbyHotspots_
+          nearbyHotspotsLatitude
+          nearbyHotspotsLongitude
+          nearbyHotspotsDist
+          nearbyHotspotsBack
+          nearbyHotspotsFmt
+      handleResponse res
+
+    HotspotInfoCommand HotspotInfoOptions{..} -> do
+      res <- askEBird $ hotspotInfo hotspotInfoLocation
+      handleResponse res
+
+    TaxonomyCommand TaxonomyOptions{..} -> do
+      res <- askEBird $
+        taxonomy_
+          taxonomyTaxonomyCategories
+          taxonomyFormat
+          taxonomySPPLocale
+          taxonomySpecies
+          taxonomyVersion
+      handleResponse res
+
+    TaxonomicFormsCommand TaxonomicFormsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        taxonomicForms_ apiKey
+          taxonomicFormsSpecies
+      handleResponse res
+
+    TaxaLocaleCodesCommand TaxaLocaleCodesOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        taxaLocaleCodes_ apiKey
+          taxaLocaleCodesAcceptLanguage
+      handleResponse res
+
+    TaxonomyVersionsCommand -> askEBird taxonomyVersions >>= handleResponse
+
+    TaxonomicGroupsCommand TaxonomicGroupsOptions{..} -> do
+      res <- askEBird $
+        taxonomicGroups_
+          taxonomicGroupsSPPGrouping
+          taxonomicGroupsSPPLocale
+      handleResponse res
+
+    RegionInfoCommand RegionInfoOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        regionInfo_ apiKey
+          regionInfoRegion
+          regionInfoRegionNameFormat
+      handleResponse res
+
+    SubRegionListCommand SubRegionListOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        subRegionList apiKey
+          subRegionListRegionType
+          subRegionListParentRegionCode
+      handleResponse res
+
+    AdjacentRegionsCommand AdjacentRegionsOptions{..} -> do
+      apiKey <- getAPIKey
+      res <- askEBird $
+        adjacentRegions apiKey
+          adjacentRegionsRegion
+      handleResponse res
+  where
+    handleResponse :: ToJSON a => Either ClientError a -> IO ()
+    handleResponse = \case
+      Right v -> printResJSON v
+      Left err ->
+        eBirdFail $
+          "An error occurred while executing the request:\n" <>
+          show err
+
+
+-- | Simply prints a value as prettified JSON
+printResJSON :: ToJSON a => a -> IO ()
+printResJSON = BS.putStrLn . BS.toStrict . encodePretty
+
+-- | Print a string to stderr, prepended with a context string, and exit with
+-- failure status.
+eBirdFail :: String -> IO a
+eBirdFail msg = do
+  progName <- getProgName
+  die
+    ( progName <> ": Something went wrong!\n\n"
+      <> unlines (map ("  " <>) (lines msg))
+    )
+
+-------------------------------------------------------------------------------
+-- Command types
+-------------------------------------------------------------------------------
+
+-- | Each 'EBirdCommand' corresponds to an endpoint of the eBird API
+data EBirdCommand
+    = RecentObservationsCommand RecentObservationsOptions
+    | RecentNotableObservationsCommand RecentNotableObservationsOptions
+    | RecentSpeciesObservationsCommand RecentSpeciesObservationsOptions
+    | RecentNearbyObservationsCommand RecentNearbyObservationsOptions
+    | RecentNearbySpeciesObservationsCommand RecentNearbySpeciesObservationsOptions
+    | RecentNearestSpeciesObservationsCommand RecentNearestSpeciesObservationsOptions
+    | RecentNearbyNotableObservationsCommand RecentNearbyNotableObservationsOptions
+    | HistoricalObservationsCommand HistoricalObservationsOptions
+    | RecentChecklistsCommand RecentChecklistsOptions
+    | Top100Command Top100Options
+    | ChecklistFeedCommand ChecklistFeedOptions
+    | RegionalStatisticsCommand RegionalStatisticsOptions
+    | SpeciesListCommand SpeciesListOptions
+    | ViewChecklistCommand ViewChecklistOptions
+    | RegionHotspotsCommand RegionHotspotsOptions
+    | NearbyHotspotsCommand NearbyHotspotsOptions
+    | HotspotInfoCommand HotspotInfoOptions
+    | TaxonomyCommand TaxonomyOptions
+    | TaxonomicFormsCommand TaxonomicFormsOptions
+    | TaxaLocaleCodesCommand TaxaLocaleCodesOptions
+    | TaxonomyVersionsCommand
+    | TaxonomicGroupsCommand TaxonomicGroupsOptions
+    | RegionInfoCommand RegionInfoOptions
+    | SubRegionListCommand SubRegionListOptions
+    | AdjacentRegionsCommand AdjacentRegionsOptions
+  deriving (Show, Eq)
+
+-- | Options for the @recent-observations@ command.
+data RecentObservationsOptions =
+    RecentObservationsOptions
+      { recentObservationsRegion :: RegionCode
+      , recentObservationsBack :: Maybe Integer
+      , recentObservationsCategories :: Maybe TaxonomyCategories
+      , recentObservationsHotspots :: Maybe Bool
+      , recentObservationsProvisionals :: Maybe Bool
+      , recentObservationsMaxResults :: Maybe Integer
+      , recentObservationsSubRegions :: Maybe RegionCode
+      , recentObservationsSPPLocale :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @recent-notable-observations@ command.
+data RecentNotableObservationsOptions =
+    RecentNotableObservationsOptions
+      { recentNotableObservationsRegion :: RegionCode
+      , recentNotableObservationsBack :: Maybe Integer
+      , recentNotableObservationsDetail :: Maybe DetailLevel
+      , recentNotableObservationsHotspots :: Maybe Bool
+      , recentNotableObservationsMaxResults :: Maybe Integer
+      , recentNotableObservationsSubRegions :: Maybe RegionCode
+      , recentNotableObservationsSPPLocale :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @recent-species-observations@ command.
+data RecentSpeciesObservationsOptions =
+    RecentSpeciesObservationsOptions
+      { recentSpeciesObservationsRegion :: RegionCode
+      , recentSpeciesObservationsSpecies :: SpeciesCode
+      , recentSpeciesObservationsBack :: Maybe Integer
+      , recentSpeciesObservationsHotspots :: Maybe Bool
+      , recentSpeciesObservationsProvisionals :: Maybe Bool
+      , recentSpeciesObservationsMaxResults :: Maybe Integer
+      , recentSpeciesObservationsSubRegions :: Maybe RegionCode
+      , recentSpeciesObservationsSPPLocale :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @recent-nearby-observations@ command.
+data RecentNearbyObservationsOptions =
+    RecentNearbyObservationsOptions
+      { recentNearbyObservationsLatitude :: Double
+      , recentNearbyObservationsLongitude :: Double
+      , recentNearbyObservationsDist :: Maybe Integer
+      , recentNearbyObservationsBack :: Maybe Integer
+      , recentNearbyObservationsCategories :: Maybe TaxonomyCategories
+      , recentNearbyObservationsHotspots :: Maybe Bool
+      , recentNearbyObservationsProvisionals :: Maybe Bool
+      , recentNearbyObservationsMaxResults :: Maybe Integer
+      , recentNearbyObservationsSortBy :: Maybe SortObservationsBy
+      , recentNearbyObservationsSPPLocale :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @recent-nearby-species-observations@ command.
+data RecentNearbySpeciesObservationsOptions =
+    RecentNearbySpeciesObservationsOptions
+      { recentNearbySpeciesObservationsSpecies :: SpeciesCode
+      , recentNearbySpeciesObservationsLatitude :: Double
+      , recentNearbySpeciesObservationsLongitude :: Double
+      , recentNearbySpeciesObservationsDist :: Maybe Integer
+      , recentNearbySpeciesObservationsBack :: Maybe Integer
+      , recentNearbySpeciesObservationsCategories :: Maybe TaxonomyCategories
+      , recentNearbySpeciesObservationsHotspots :: Maybe Bool
+      , recentNearbySpeciesObservationsProvisionals :: Maybe Bool
+      , recentNearbySpeciesObservationsMaxResults :: Maybe Integer
+      , recentNearbySpeciesObservationsSortBy :: Maybe SortObservationsBy
+      , recentNearbySpeciesObservationsSPPLocale :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @recent-nearest-species-observations@ command.
+data RecentNearestSpeciesObservationsOptions =
+    RecentNearestSpeciesObservationsOptions
+      { recentNearestSpeciesObservationsSpecies :: SpeciesCode
+      , recentNearestSpeciesObservationsLatitude :: Double
+      , recentNearestSpeciesObservationsLongitude :: Double
+      , recentNearestSpeciesObservationsDist :: Maybe Integer
+      , recentNearestSpeciesObservationsBack :: Maybe Integer
+      , recentNearestSpeciesObservationsHotspots :: Maybe Bool
+      , recentNearestSpeciesObservationsProvisionals :: Maybe Bool
+      , recentNearestSpeciesObservationsMaxResults :: Maybe Integer
+      , recentNearestSpeciesObservationsSPPLocale :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @recent-nearby-notable-observations@ command.
+data RecentNearbyNotableObservationsOptions =
+    RecentNearbyNotableObservationsOptions
+      { recentNearbyNotableObservationsLatitude :: Double
+      , recentNearbyNotableObservationsLongitude :: Double
+      , recentNearbyNotableObservationsDist :: Maybe Integer
+      , recentNearbyNotableObservationsDetail :: Maybe DetailLevel
+      , recentNearbyNotableObservationsBack :: Maybe Integer
+      , recentNearbyNotableObservationsHotspots :: Maybe Bool
+      , recentNearbyNotableObservationsMaxResults :: Maybe Integer
+      , recentNearbyNotableObservationsSPPLocale :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @historical-observations@ command.
+data HistoricalObservationsOptions =
+    HistoricalObservationsOptions
+      { historicalObservationsRegion :: RegionCode
+      , historicalObservationsDate :: EBirdDate
+      , historicalObservationsCategories :: Maybe TaxonomyCategories
+      , historicalObservationsDetail :: Maybe DetailLevel
+      , historicalObservationsHotspots :: Maybe Bool
+      , historicalObservationsProvisionals :: Maybe Bool
+      , historicalObservationsMaxResults :: Maybe Integer
+      , historicalObservationsRank :: Maybe SelectObservation
+      , historicalObservationsSubRegions :: Maybe RegionCode
+      , historicalObservationsSPPLocale :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @recent-checklists@ command.
+data RecentChecklistsOptions =
+    RecentChecklistsOptions
+      { recentChecklistsRegion :: RegionCode
+      , recentChecklistsMaxResults :: Maybe Integer
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @top-100@ command.
+data Top100Options =
+    Top100Options
+      { top100Region :: Region
+      , top100Date :: EBirdDate
+      , top100RankedBy :: Maybe RankTop100By
+      , top100MaxResults :: Maybe Integer
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @checklist-feed@ command.
+data ChecklistFeedOptions =
+    ChecklistFeedOptions
+      { checklistFeedRegion :: Region
+      , checklistFeedDate :: EBirdDate
+      , checklistFeedSortBy :: Maybe SortChecklistsBy
+      , checklistFeedMaxResults :: Maybe Integer
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @regional-statistics@ command.
+data RegionalStatisticsOptions =
+    RegionalStatisticsOptions
+      { regionalStatisticsRegion :: Region
+      , regionalStatisticsDate :: EBirdDate
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @species-list@ command.
+newtype SpeciesListOptions =
+    SpeciesListOptions
+      { speciesListRegion :: Region
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @view-checklist@ command.
+newtype ViewChecklistOptions =
+    ViewChecklistOptions
+      { viewChecklistSubId :: Text
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @region-hotspots@ command.
+data RegionHotspotsOptions =
+    RegionHotspotsOptions
+      { regionHotspotsRegion :: RegionCode
+      , regionHotspotsBack :: Maybe Integer
+      , regionHotspotsFmt :: Maybe CSVOrJSONFormat
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @nearby-hotspots@ command.
+data NearbyHotspotsOptions =
+    NearbyHotspotsOptions
+      { nearbyHotspotsLatitude :: Double
+      , nearbyHotspotsLongitude :: Double
+      , nearbyHotspotsBack :: Maybe Integer
+      , nearbyHotspotsDist :: Maybe Integer
+      , nearbyHotspotsFmt :: Maybe CSVOrJSONFormat
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @hotspot-info@ command.
+newtype HotspotInfoOptions =
+    HotspotInfoOptions
+      { hotspotInfoLocation :: Text
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @nearby-hotspots@ command.
+data TaxonomyOptions =
+    TaxonomyOptions
+      { taxonomyTaxonomyCategories :: Maybe TaxonomyCategories
+      , taxonomyFormat :: Maybe CSVOrJSONFormat
+      , taxonomySPPLocale :: Maybe SPPLocale
+      , taxonomySpecies :: Maybe SpeciesCodes
+      , taxonomyVersion :: Maybe Text
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @taxonomic-forms@ command.
+newtype TaxonomicFormsOptions =
+    TaxonomicFormsOptions
+      { taxonomicFormsSpecies :: SpeciesCode
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @taxa-locale-codes@ command.
+newtype TaxaLocaleCodesOptions =
+    TaxaLocaleCodesOptions
+      { taxaLocaleCodesAcceptLanguage :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @taxonomic-groups@ command.
+data TaxonomicGroupsOptions =
+    TaxonomicGroupsOptions
+      { taxonomicGroupsSPPGrouping :: SPPGrouping
+      , taxonomicGroupsSPPLocale :: Maybe SPPLocale
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @region-info@ command.
+data RegionInfoOptions =
+    RegionInfoOptions
+      { regionInfoRegion :: Region
+      , regionInfoRegionNameFormat :: Maybe RegionNameFormat
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @sub-regions@ command.
+data SubRegionListOptions =
+    SubRegionListOptions
+      { subRegionListParentRegionCode :: RegionCode
+      , subRegionListRegionType :: RegionType
+      }
+  deriving (Show, Read, Eq)
+
+-- | Options for the @sub-regions@ command.
+newtype AdjacentRegionsOptions =
+    AdjacentRegionsOptions
+      { adjacentRegionsRegion :: Region
+      }
+  deriving (Show, Read, Eq)
+
+-------------------------------------------------------------------------------
+-- Command/option/flag parsers
+-------------------------------------------------------------------------------
+
+-- | Parse a command provided to the @ebird@ CLI.
+eBirdCommand :: Parser (Maybe Text, EBirdCommand)
+eBirdCommand =
+        (,)
+    <$> optionalAPIKey
+    <*>
+      (     subparser
+              (    commandGroup "Observation commands:"
+                <> command "observations" recentObservationsInfo
+                <> command "notable-observations" recentNotableObservationsInfo
+                <> command "species-observations" recentSpeciesObservationsInfo
+                <> command "nearby-observations" recentNearbyObservationsInfo
+                <> command "nearby-species-observations" recentNearbySpeciesObservationsInfo
+                <> command "nearest-species-observations" recentNearestSpeciesObservationsInfo
+                <> command "nearby-notable-observations" recentNearbyNotableObservationsInfo
+                <> command "historical-observations" historicalObservationsInfo
+              )
+        <|> subparser
+              (
+                commandGroup "Product commands:"
+                <> command "recent-checklists" recentChecklistsInfo
+                <> command "top-100" top100Info
+                <> command "checklist-feed" checklistFeedInfo
+                <> command "regional-statistics" regionalStatisticsInfo
+                <> command "species-list" speciesListInfo
+                <> command "view-checklist" viewChecklistInfo
+                <> hidden
+              )
+        <|> subparser
+              (
+                   commandGroup "Hotspot commands:"
+                <> command "region-hotspots" regionHotspotsInfo
+                <> command "nearby-hotspots" nearbyHotspotsInfo
+                <> command "hotspot-info" hotspotInfoInfo
+                <> hidden
+              )
+        <|> subparser
+              (
+                   commandGroup "Taxonomy commands:"
+                <> command "taxonomy" taxonomyInfo
+                <> command "taxonomic-forms" taxonomicFormsInfo
+                <> command "taxa-locale-codes" taxaLocaleCodesInfo
+                <> command "taxonomy-versions" taxonomyVersionsInfo
+                <> command "taxonomic-groups" taxonomicGroupsInfo
+                <> hidden
+              )
+        <|> subparser
+              (
+                   commandGroup "Region commands:"
+                <> command "region-info" regionInfoInfo
+                <> command "sub-regions" subRegionsInfo
+                <> command "adjacent-regions" adjacentRegionsInfo
+                <> hidden
+              )
+      )
+  where
+    optionalAPIKey :: Parser (Maybe Text)
+    optionalAPIKey =
+      optional
+        ( strOption
+            (    long "api-key"
+              <> short 'k'
+              <> metavar "API_KEY"
+              <> help "Specify an eBird API key"
+            )
+        )
+
+    recentObservationsInfo :: ParserInfo EBirdCommand
+    recentObservationsInfo =
+      info
+        (RecentObservationsCommand <$> recentObservationsOptions)
+        (progDesc "Get recent observations within a region")
+
+    recentNotableObservationsInfo :: ParserInfo EBirdCommand
+    recentNotableObservationsInfo =
+      info
+        (RecentNotableObservationsCommand <$> recentNotableObservationsOptions)
+        (progDesc "Get recent notable observations within a region")
+
+    recentSpeciesObservationsInfo :: ParserInfo EBirdCommand
+    recentSpeciesObservationsInfo =
+      info
+        (RecentSpeciesObservationsCommand <$> recentSpeciesObservationsOptions)
+        (progDesc "Get recent observations of a species within a region")
+
+    recentNearbyObservationsInfo :: ParserInfo EBirdCommand
+    recentNearbyObservationsInfo =
+      info
+        (RecentNearbyObservationsCommand <$> recentNearbyObservationsOptions)
+        (progDesc "Get recent observations within some radius of a latitude/longitude")
+
+    recentNearbySpeciesObservationsInfo :: ParserInfo EBirdCommand
+    recentNearbySpeciesObservationsInfo =
+      info
+        (RecentNearbySpeciesObservationsCommand <$> recentNearbySpeciesObservationsOptions)
+        (progDesc "Get recent observations of a species within some radius of a latitude/longitude")
+
+    recentNearestSpeciesObservationsInfo :: ParserInfo EBirdCommand
+    recentNearestSpeciesObservationsInfo =
+      info
+        (RecentNearestSpeciesObservationsCommand <$> recentNearestSpeciesObservationsOptions)
+        (progDesc "Get recent observations of a species nearest to a latitude/longitude")
+
+    recentNearbyNotableObservationsInfo :: ParserInfo EBirdCommand
+    recentNearbyNotableObservationsInfo =
+      info
+        (RecentNearbyNotableObservationsCommand <$> recentNearbyNotableObservationsOptions)
+        (progDesc "Get recent notable observations within some radius of a latitude/longitude")
+
+    historicalObservationsInfo :: ParserInfo EBirdCommand
+    historicalObservationsInfo =
+      info
+        (HistoricalObservationsCommand <$> historicalObservationsOptions)
+        (progDesc "Get a list of observations for each species seen in a region on a specific date")
+
+    recentChecklistsInfo :: ParserInfo EBirdCommand
+    recentChecklistsInfo =
+      info
+        (RecentChecklistsCommand <$> recentChecklistsOptions)
+        (progDesc "Get recent checklists within a region")
+
+    top100Info :: ParserInfo EBirdCommand
+    top100Info =
+      info
+        (Top100Command <$> top100Options)
+        (progDesc "Get the top 100 contributors in a region for a given date")
+
+    checklistFeedInfo :: ParserInfo EBirdCommand
+    checklistFeedInfo =
+      info
+        (ChecklistFeedCommand <$> checklistFeedOptions)
+        (progDesc "Get the checklist feed in a region for a given date")
+
+    regionalStatisticsInfo :: ParserInfo EBirdCommand
+    regionalStatisticsInfo =
+      info
+        (RegionalStatisticsCommand <$> regionalStatisticsOptions)
+        (progDesc "Get the regional statistics for a region on a given date")
+
+    speciesListInfo :: ParserInfo EBirdCommand
+    speciesListInfo =
+      info
+        (SpeciesListCommand <$> speciesListOptions)
+        (progDesc "Get the list of all species ever observed in a region")
+
+    viewChecklistInfo :: ParserInfo EBirdCommand
+    viewChecklistInfo =
+      info
+        (ViewChecklistCommand <$> viewChecklistOptions)
+        (progDesc "Get information about a particular checklist")
+
+    regionHotspotsInfo :: ParserInfo EBirdCommand
+    regionHotspotsInfo =
+      info
+        (RegionHotspotsCommand <$> regionHotspotsOptions)
+        (progDesc "Get a list of hotspots in one or more regions")
+
+    nearbyHotspotsInfo :: ParserInfo EBirdCommand
+    nearbyHotspotsInfo =
+      info
+        (NearbyHotspotsCommand <$> nearbyHotspotsOptions)
+        (progDesc "Get a list of hotspots within some radius of a latitude/longitude")
+
+    hotspotInfoInfo :: ParserInfo EBirdCommand
+    hotspotInfoInfo =
+      info
+        (HotspotInfoCommand <$> hotspotInfoOptions)
+        (progDesc "Get information about a hotspot")
+
+    taxonomyInfo :: ParserInfo EBirdCommand
+    taxonomyInfo =
+      info
+        (TaxonomyCommand <$> taxonomyOptions)
+        (progDesc "Get any version of the eBird taxonomy")
+
+    taxonomicFormsInfo :: ParserInfo EBirdCommand
+    taxonomicFormsInfo =
+      info
+        (TaxonomicFormsCommand <$> taxonomicFormsOptions)
+        (progDesc "Get the subspecies of a given species recognized by the taxonomy")
+
+    taxaLocaleCodesInfo :: ParserInfo EBirdCommand
+    taxaLocaleCodesInfo =
+      info
+        (TaxaLocaleCodesCommand <$> taxaLocaleCodesOptions)
+        (progDesc "Get the supported locale codes and names for species common names")
+
+    taxonomyVersionsInfo :: ParserInfo EBirdCommand
+    taxonomyVersionsInfo =
+      info
+        (pure TaxonomyVersionsCommand <**> helper)
+        (progDesc "Get the complete list of taxonomy versions, with a flag indicating which is the latest")
+
+    taxonomicGroupsInfo :: ParserInfo EBirdCommand
+    taxonomicGroupsInfo =
+      info
+        (TaxonomicGroupsCommand <$> taxonomicGroupsOptions)
+        (progDesc "Get the list of species groups in either Merlin or eBird grouping")
+
+    regionInfoInfo :: ParserInfo EBirdCommand
+    regionInfoInfo =
+      info
+        (RegionInfoCommand <$> regionInfoOptions)
+        (progDesc "Get information about a region")
+
+    subRegionsInfo :: ParserInfo EBirdCommand
+    subRegionsInfo =
+      info
+        (SubRegionListCommand <$> subRegionListOptions)
+        (progDesc "Get the list of sub-regions within a region")
+
+    adjacentRegionsInfo :: ParserInfo EBirdCommand
+    adjacentRegionsInfo =
+      info
+        (AdjacentRegionsCommand <$> adjacentRegionsOptions)
+        (progDesc "Get the list of regions that are adjacent to a region")
+
+-------------------------------------------------------------------------------
+-- Parsers for observation command options
+-------------------------------------------------------------------------------
+
+-- | Parse the options for the @recent-observations@ command.
+recentObservationsOptions :: Parser RecentObservationsOptions
+recentObservationsOptions =
+        RecentObservationsOptions
+    <$> regionCodeOpt "observations"
+    <*> optional (backOpt "observations" "submitted" (Just 14))
+    <*> optional (taxonomyCategoriesOpt "observations of")
+    <*> optional observationOnlyHotspotsOpt
+    <*> optional observationIncludeProvisionalOpt
+    <*> optional observationMaxResultsOpt
+    <*> optional extraRegionsOpt
+    <*> optional (sppLocaleOpt "common")
+    <**> helper
+
+-- | Parse the options for a @recent-notable-observations@ command.
+recentNotableObservationsOptions :: Parser RecentNotableObservationsOptions
+recentNotableObservationsOptions =
+        RecentNotableObservationsOptions
+    <$> regionCodeOpt "observations"
+    <*> optional (backOpt "observations" "submitted" (Just 14))
+    <*> optional observationDetailLevelOpt
+    <*> optional observationOnlyHotspotsOpt
+    <*> optional observationMaxResultsOpt
+    <*> optional extraRegionsOpt
+    <*> optional (sppLocaleOpt "common")
+    <**> helper
+
+-- | Parse the options for a @recent-species-observations@ command.
+recentSpeciesObservationsOptions :: Parser RecentSpeciesObservationsOptions
+recentSpeciesObservationsOptions =
+        RecentSpeciesObservationsOptions
+    <$> regionCodeOpt "observations"
+    <*> speciesCodeOpt "observations"
+    <*> optional (backOpt "observations" "submitted" (Just 14))
+    <*> optional observationOnlyHotspotsOpt
+    <*> optional observationIncludeProvisionalOpt
+    <*> optional observationMaxResultsOpt
+    <*> optional extraRegionsOpt
+    <*> optional (sppLocaleOpt "common")
+    <**> helper
+
+-- | Parse the options for a @recent-nearby-observations@ command.
+recentNearbyObservationsOptions :: Parser RecentNearbyObservationsOptions
+recentNearbyObservationsOptions =
+        RecentNearbyObservationsOptions
+    <$> latLngOpt "latitude" "observations"
+    <*> latLngOpt "longitude" "observations"
+    <*> optional (searchRadiusOpt "observations" 50 (Just 25))
+    <*> optional (backOpt "observations" "submitted" (Just 14))
+    <*> optional (taxonomyCategoriesOpt "observations of")
+    <*> optional observationOnlyHotspotsOpt
+    <*> optional observationIncludeProvisionalOpt
+    <*> optional observationMaxResultsOpt
+    <*> optional observationSortByOpt
+    <*> optional (sppLocaleOpt "common")
+    <**> helper
+
+-- | Parse the options for a @recent-nearby-species-observations@ command.
+recentNearbySpeciesObservationsOptions :: Parser RecentNearbySpeciesObservationsOptions
+recentNearbySpeciesObservationsOptions =
+        RecentNearbySpeciesObservationsOptions
+    <$> speciesCodeOpt "observations"
+    <*> latLngOpt "latitude" "observations"
+    <*> latLngOpt "longitude" "observations"
+    <*> optional (searchRadiusOpt "observations" 50 (Just 25))
+    <*> optional (backOpt "observations" "submitted" (Just 14))
+    <*> optional (taxonomyCategoriesOpt "observations of")
+    <*> optional observationOnlyHotspotsOpt
+    <*> optional observationIncludeProvisionalOpt
+    <*> optional observationMaxResultsOpt
+    <*> optional observationSortByOpt
+    <*> optional (sppLocaleOpt "common")
+    <**> helper
+
+-- | Parse the options for a @recent-nearest-species-observations@ command.
+recentNearestSpeciesObservationsOptions :: Parser RecentNearestSpeciesObservationsOptions
+recentNearestSpeciesObservationsOptions =
+        RecentNearestSpeciesObservationsOptions
+    <$> speciesCodeOpt "observations"
+    <*> latLngOpt "latitude" "observations"
+    <*> latLngOpt "longitude" "observations"
+    <*> optional (searchRadiusOpt "observations" 50 Nothing)
+    <*> optional (backOpt "observations" "submitted" (Just 14))
+    <*> optional observationOnlyHotspotsOpt
+    <*> optional observationIncludeProvisionalOpt
+    <*> optional observationMaxResultsOpt
+    <*> optional (sppLocaleOpt "common")
+    <**> helper
+
+-- | Parse the options for a @recent-nearest-species-observations@ command.
+recentNearbyNotableObservationsOptions :: Parser RecentNearbyNotableObservationsOptions
+recentNearbyNotableObservationsOptions =
+        RecentNearbyNotableObservationsOptions
+    <$> latLngOpt "latitude" "observations"
+    <*> latLngOpt "longitude" "observations"
+    <*> optional (searchRadiusOpt "observations" 50 (Just 25))
+    <*> optional observationDetailLevelOpt
+    <*> optional (backOpt "observations" "submitted" (Just 14))
+    <*> optional observationOnlyHotspotsOpt
+    <*> optional observationMaxResultsOpt
+    <*> optional (sppLocaleOpt "common")
+    <**> helper
+
+-- | Parse the options for a @historical-observations@ command.
+historicalObservationsOptions :: Parser HistoricalObservationsOptions
+historicalObservationsOptions =
+        HistoricalObservationsOptions
+    <$> regionCodeOpt "observations"
+    <*> dateOpt
+    <*> optional (taxonomyCategoriesOpt "observations of")
+    <*> optional observationDetailLevelOpt
+    <*> optional observationOnlyHotspotsOpt
+    <*> optional observationIncludeProvisionalOpt
+    <*> optional observationMaxResultsOpt
+    <*> optional rankOpt
+    <*> optional extraRegionsOpt
+    <*> optional (sppLocaleOpt "common")
+    <**> helper
+  where
+    dateOpt :: Parser EBirdDate
+    dateOpt =
+      option (attoReadM parseEBirdDate)
+        (    long "date"
+          <> metavar "YYYY-MM-DD"
+          <> help ( "Specify the date to fetch observations from (year " ++
+                    "1800 to present)"
+                  )
+        )
+
+    rankOpt :: Parser SelectObservation
+    rankOpt =
+      option (attoReadM parseSelectObservation)
+        (    long "select"
+          <> metavar "SELECT"
+          <> help ( "Specify whether to select the first or last " ++
+                    "observation of a species if there are multiple " ++
+                    "(\"first\" or \"last\") (default: last)"
+                  )
+        )
+
+-- | Parse a 'GHC.Types.Bool', intended to be used as an option determining whether to
+-- include observations from hotspots in the response.
+observationOnlyHotspotsOpt :: Parser Bool
+observationOnlyHotspotsOpt =
+    switch
+      (    long "only-hotspots"
+        <> help "Only include observations from hotspots"
+      )
+
+-- | Parse a 'GHC.Types.Bool', intended to be used as an option determining whether to
+-- include unreviewed observations in the response.
+observationIncludeProvisionalOpt :: Parser Bool
+observationIncludeProvisionalOpt =
+    switch
+      (    long "include-provisional"
+        <> help "Include observations which have not yet been reviewed"
+      )
+
+-- | Parse a 'Integer' option indicating the number of results to include in the
+-- response.
+observationMaxResultsOpt :: Parser Integer
+observationMaxResultsOpt =
+    option (attoReadM A.decimal)
+      (    long "max-results"
+        <> metavar "N"
+        <> help ( "Specify the max number of observations to include " ++
+                  "(1 to 10000, default: all)"
+                )
+      )
+
+-- | Parse a 'RegionCode' as a generic command option, intended for use with the
+-- observation commands.
+regionCodeOpt
+  :: String
+  -- ^ What are we fetching? (e.g. "observations")
+  -> Parser RegionCode
+regionCodeOpt thing =
+    option (attoReadM parseRegionCode)
+      (    long "region"
+        <> metavar "REGION_CODE"
+        <> help helpStr
+      )
+  where
+    helpStr :: String
+    helpStr =
+      printf
+        ( "Specify the regions to fetch %s from " ++
+          "(e.g. \"US-WY,US-CO,US-ID\" or \"US-CA-037\")"
+        )
+        thing
+
+-- | Configurable 'TaxonomyCategories' parser
+taxonomyCategoriesOpt
+  :: String
+  -- ^ String to include after "one or more taxonomy categories to include ..."
+  -> Parser TaxonomyCategories
+taxonomyCategoriesOpt desc =
+    option (attoReadM parseTaxonomyCategories)
+      (    long "taxonomy-categories"
+        <> metavar "CATEGORIES"
+        <> help helpStr
+      )
+  where
+    helpStr :: String
+    helpStr =
+      printf
+        ( "Specify a list of one or more taxonomy categories to include " ++
+          "%s (e.g. \"issf\" or \"hybrid\") (default: all categories)"
+        )
+        desc
+
+-- | Parse a 'SortObservationsBy' as an option determining how returned
+-- observations will be sorted.
+observationSortByOpt :: Parser SortObservationsBy
+observationSortByOpt =
+    option (attoReadM parseSortObservationsBy)
+      (    long "sort-by"
+        <> metavar "SORT_BY"
+        <> help ( "Specify the ordering to use for the resulting " ++
+                  "observations (\"date\" or \"species\") (default: date, " ++
+                  "earliest first)"
+                )
+      )
+
+-- | Parse a 'DetailLevel' as an option determining the detail level of
+-- observations in the result.
+observationDetailLevelOpt :: Parser DetailLevel
+observationDetailLevelOpt =
+    option (attoReadM parseDetailLevel)
+      (    long "detail"
+        <> metavar "DETAIL_LEVEL"
+        <> help ( "Specify the detail level of the returned observations " ++
+                  "(\"simple\" or \"full\") (default: simple)"
+                )
+      )
+
+-- | Parse a 'RegionCode' as extra regions to fetch observations from
+extraRegionsOpt :: Parser RegionCode
+extraRegionsOpt =
+  option (attoReadM parseRegionCode)
+    (    long "extra-regions"
+      <> metavar "REGION_CODE"
+      <> help "Up to 10 extra regions to fetch observations from"
+    )
+
+-------------------------------------------------------------------------------
+-- Parsers for product command options
+-------------------------------------------------------------------------------
+
+-- | Parse the options for a @recent-checklists@ command.
+recentChecklistsOptions :: Parser RecentChecklistsOptions
+recentChecklistsOptions =
+        RecentChecklistsOptions
+    <$> checklistRegionCodeOpt
+    <*> optional checklistMaxResultsOpt
+    <**> helper
+
+-- | Parse the options for a @top-100@ command.
+top100Options :: Parser Top100Options
+top100Options =
+        Top100Options
+    <$> regionOpt
+    <*> dateOpt
+    <*> optional rankedByOpt
+    <*> optional maxResultsOpt
+    <**> helper
+  where
+    regionOpt :: Parser Region
+    regionOpt =
+      option (attoReadM parseRegion)
+        (    long "region"
+          <> metavar "REGION"
+          <> help ( "Specify the region to fetch the top 100 contributors " ++
+                    "for (e.g. \"US-WY\" or \"US-CA-037\")"
+                  )
+        )
+
+    dateOpt :: Parser EBirdDate
+    dateOpt =
+      option (attoReadM parseEBirdDate)
+        (    long "date"
+          <> metavar "YYYY-MM-DD"
+          <> help ( "Specify the date to fetch the top contributors on " ++
+                    "(year 1800 to present)"
+                  )
+        )
+
+    rankedByOpt :: Parser RankTop100By
+    rankedByOpt =
+      option (attoReadM parseRankTop100By)
+        (    long "rank-by"
+          <> metavar "RANK_BY"
+          <> help ( "Specify whether to rank contributors by number of " ++
+                    "species observed (\"spp\") or number of checklists " ++
+                    "completed (\"cl\") (default: spp)"
+                  )
+        )
+
+    maxResultsOpt :: Parser Integer
+    maxResultsOpt =
+      option (attoReadM A.decimal)
+        (    long "max-results"
+          <> metavar "N"
+          <> help ( "Specify the max number of contributors to include " ++
+                    "(1 to 100, default: 100)"
+                  )
+        )
+
+-- | Parse the options for a @top-100@ command.
+checklistFeedOptions :: Parser ChecklistFeedOptions
+checklistFeedOptions =
+        ChecklistFeedOptions
+    <$> regionOpt
+    <*> dateOpt
+    <*> optional sortByOpt
+    <*> optional maxResultsOpt
+    <**> helper
+  where
+    regionOpt :: Parser Region
+    regionOpt =
+      option (attoReadM parseRegion)
+        (    long "region"
+          <> metavar "REGION"
+          <> help ( "Specify the region to fetch the checklist feed " ++
+                    "for (e.g. \"US-WY\" or \"US-CA-037\")"
+                  )
+        )
+
+    dateOpt :: Parser EBirdDate
+    dateOpt =
+      option (attoReadM parseEBirdDate)
+        (    long "date"
+          <> metavar "YYYY-MM-DD"
+          <> help ( "Specify the date to fetch the checklist feed on " ++
+                    "(year 1800 to present)"
+                  )
+        )
+
+    sortByOpt :: Parser SortChecklistsBy
+    sortByOpt =
+      option (attoReadM parseSortChecklistsBy)
+        (    long "sort-by"
+          <> metavar "SORT_BY"
+          <> help ( "Specify whether to sort the checklist fee by date of " ++
+                    "creation (\"obs_dt\") or date of submission " ++
+                    "(\"creation_dt\") (default: obs_dt)"
+                  )
+        )
+
+    maxResultsOpt :: Parser Integer
+    maxResultsOpt =
+      option (attoReadM A.decimal)
+        (    long "max-results"
+          <> metavar "N"
+          <> help ( "Specify the max number of checklists to include " ++
+                    "(1 to 200, default: 10)"
+                  )
+        )
+
+-- | Parse the options for a @regional-statistics@ command.
+regionalStatisticsOptions :: Parser RegionalStatisticsOptions
+regionalStatisticsOptions =
+        RegionalStatisticsOptions
+    <$> regionOpt
+    <*> dateOpt
+    <**> helper
+  where
+    regionOpt :: Parser Region
+    regionOpt =
+      option (attoReadM parseRegion)
+        (    long "region"
+          <> metavar "REGION"
+          <> help ( "Specify the region to fetch the statistics for " ++
+                    "(e.g. \"US-WY\" or \"US-CA-037\")"
+                  )
+        )
+
+    dateOpt :: Parser EBirdDate
+    dateOpt =
+      option (attoReadM parseEBirdDate)
+        (    long "date"
+          <> metavar "YYYY-MM-DD"
+          <> help ( "Specify the date to fetch the statistics on (year " ++
+                    "1800 to present)"
+                  )
+        )
+
+-- | Parse the options for a @regional-statistics@ command.
+speciesListOptions :: Parser SpeciesListOptions
+speciesListOptions =
+        SpeciesListOptions
+    <$> regionOpt
+    <**> helper
+  where
+    regionOpt :: Parser Region
+    regionOpt =
+      option (attoReadM parseRegion)
+        (    long "region"
+          <> metavar "REGION"
+          <> help ( "Specify the region to fetch the species list for " ++
+                    "(e.g. \"US-WY\" or \"US-CA-037\")"
+                  )
+        )
+
+-- | Parse the options for a @regional-statistics@ command.
+viewChecklistOptions :: Parser ViewChecklistOptions
+viewChecklistOptions =
+        ViewChecklistOptions
+    <$> subIdOpt
+    <**> helper
+  where
+    subIdOpt :: Parser Text
+    subIdOpt =
+      option (attoReadM A.takeText)
+        (    long "submission-id"
+          <> metavar "SUBMISSION_ID"
+          <> help ( "Specify the submission ID of the checklist to view " ++
+                    "(e.g. \"S144646447\")"
+                  )
+        )
+
+-- | Parse a 'RegionCode' as a generic command option, intended for use with the
+-- checklists commands.
+checklistRegionCodeOpt :: Parser RegionCode
+checklistRegionCodeOpt =
+    option (attoReadM parseRegionCode)
+      (    long "region"
+        <> metavar "REGION_CODE"
+        <> help ( "Specify the regions to fetch checklists from " ++
+                  "(e.g. \"US-WY,US-CO\" or \"US-CA-037\")"
+                )
+      )
+
+-- | Parse a 'Integer' as a generic command option, intended for use with the
+-- checklists commands for determining max results to include.
+checklistMaxResultsOpt :: Parser Integer
+checklistMaxResultsOpt =
+    option (attoReadM A.decimal)
+      (    long "max-results"
+        <> metavar "N"
+        <> help ( "Specify the max number of checklists to include " ++
+                  "(1 to 200, default: 10)"
+                )
+      )
+
+-------------------------------------------------------------------------------
+-- Parsers for hotspot command options
+-------------------------------------------------------------------------------
+
+-- | Parse the options for a @region-hotspots@ command.
+regionHotspotsOptions :: Parser RegionHotspotsOptions
+regionHotspotsOptions =
+        RegionHotspotsOptions
+    <$> regionCodeOpt "hotspots"
+    <*> optional (backOpt "hotspots" "visited" Nothing)
+    <*> pure (Just JSONFormat)
+    <**> helper
+
+-- | Parse the options for a @nearby-hotspots@ command.
+nearbyHotspotsOptions :: Parser NearbyHotspotsOptions
+nearbyHotspotsOptions =
+        NearbyHotspotsOptions
+    <$> latLngOpt "latitude" "hotspots"
+    <*> latLngOpt "longitude" "hotspots"
+    <*> optional (backOpt "hotspots" "visited" Nothing)
+    <*> optional (searchRadiusOpt "hotspots" 50 (Just 25))
+    <*> pure (Just JSONFormat)
+    <**> helper
+
+-- | Parse the options for a @hotspot-info@ command.
+hotspotInfoOptions :: Parser HotspotInfoOptions
+hotspotInfoOptions =
+        HotspotInfoOptions
+    <$> locationCodeOpt
+    <**> helper
+  where
+    locationCodeOpt :: Parser Text
+    locationCodeOpt =
+      option (attoReadM A.takeText)
+        (    long "location"
+          <> metavar "LOCATION"
+          <> help "Location code of the hotspot (e.g. \"L5044136\")"
+        )
+-------------------------------------------------------------------------------
+-- Parsers for taxonomy command options
+-------------------------------------------------------------------------------
+
+-- | Parse the options for a @nearby-hotspots@ command.
+taxonomyOptions :: Parser TaxonomyOptions
+taxonomyOptions =
+        TaxonomyOptions
+    <$> optional (taxonomyCategoriesOpt "in the taxonomy")
+    <*> pure (Just JSONFormat)
+    <*> optional (sppLocaleOpt "common")
+    <*> optional speciesCodesOpt
+    <*> optional taxonomyVersionOpt
+    <**> helper
+  where
+    speciesCodesOpt :: Parser SpeciesCodes
+    speciesCodesOpt =
+      option (attoReadM parseSpeciesCodes)
+        (    long "species"
+          <> metavar "SPECIES_CODES"
+          <> help "Only include entries for these species (default: all)"
+        )
+
+    taxonomyVersionOpt :: Parser Text
+    taxonomyVersionOpt =
+      option (attoReadM A.takeText)
+        (    long "version"
+          <> metavar "VERSION"
+          <> help "Taxonomy version to fetch (default: latest)"
+        )
+
+-- | Parse the options for a @taxonomic-forms@ command.
+taxonomicFormsOptions :: Parser TaxonomicFormsOptions
+taxonomicFormsOptions =
+        TaxonomicFormsOptions
+    <$> speciesCodeOpt "subspecies"
+    <**> helper
+
+-- | Parse the options for a @taxa-locale-codes@ command.
+taxaLocaleCodesOptions :: Parser TaxaLocaleCodesOptions
+taxaLocaleCodesOptions =
+        TaxaLocaleCodesOptions
+    <$> optional acceptLanguageOpt
+    <**> helper
+  where
+    acceptLanguageOpt :: Parser SPPLocale
+    acceptLanguageOpt =
+      option (attoReadM parseSPPLocale)
+        (    long "accept-language"
+          <> metavar "LOCALE"
+          <> help ( "Get language names translated to this locale, when " ++
+                    "available (default: en)"
+                  )
+        )
+
+-- | Parse the options for a @taxa-locale-codes@ command.
+taxonomicGroupsOptions :: Parser TaxonomicGroupsOptions
+taxonomicGroupsOptions =
+        TaxonomicGroupsOptions
+    <$> sppGroupingOpt
+    <*> optional (sppLocaleOpt "group")
+    <**> helper
+  where
+    sppGroupingOpt :: Parser SPPGrouping
+    sppGroupingOpt =
+      option (attoReadM parseSPPGrouping)
+        (    long "spp-grouping"
+          <> metavar "GROUPING"
+          <> help ( "Group species using Merlin or eBird grouping " ++
+                    "(\"merlin\" or \"ebird\")"
+                  )
+        )
+
+-------------------------------------------------------------------------------
+-- Parsers for region command options
+-------------------------------------------------------------------------------
+
+-- | Parse the options for the @region-info@ command.
+regionInfoOptions :: Parser RegionInfoOptions
+regionInfoOptions =
+        RegionInfoOptions
+    <$> region
+    <*> optional regionNameFormat
+    <**> helper
+
+-- | Parse the options for the @sub-regions@ command.
+subRegionListOptions :: Parser SubRegionListOptions
+subRegionListOptions =
+        SubRegionListOptions
+    <$> regionCode
+    <*> regionType
+    <**> helper
+
+-- | Parse the options for the @sub-regions@ command.
+adjacentRegionsOptions :: Parser AdjacentRegionsOptions
+adjacentRegionsOptions =
+        AdjacentRegionsOptions
+    <$> regionOpt
+    <**> helper
+  where
+    regionOpt :: Parser Region
+    regionOpt =
+      option (attoReadM parseRegion)
+        (    long "region"
+          <> metavar "REGION"
+          <> help ( "Specify the region to get adjacent regions of " ++
+                    "(e.g. \"US-WY\")"
+                  )
+        )
+
+-------------------------------------------------------------------------------
+-- Generic single option parsers
+-------------------------------------------------------------------------------
+
+-- | Parse a 'SPPLocale' as a generic command option.
+sppLocaleOpt :: String -> Parser SPPLocale
+sppLocaleOpt nameType =
+  option (attoReadM parseSPPLocale)
+    (    long "spp-locale"
+      <> metavar "LOCALE"
+      <> help helpStr
+    )
+  where
+    helpStr :: String
+    helpStr =
+      printf
+        "Specify a locale to use for %s names"
+        nameType
+
+-- | Parse a 'Region' as a generic command option.
+region :: Parser Region
+region = option (attoReadM parseRegion)
+    (    long "region"
+      <> metavar "REGION"
+      <> help "Specify a region (e.g. \"world\" or \"US-WY\")"
+    )
+
+-- | Parse a 'RegionCode' as a generic command option.
+regionCode :: Parser RegionCode
+regionCode = option (attoReadM parseRegionCode)
+    (    long "region"
+      <> metavar "REGION_CODE"
+      <> help "Specify a region code (e.g. \"world\" or \"US-MT,US-WY\")"
+    )
+
+-- | Parse a 'RegionNameFormat' as a generic command option.
+regionNameFormat :: Parser RegionNameFormat
+regionNameFormat = option (attoReadM parseRegionNameFormat)
+    (    long "region-name-format"
+      <> metavar "REGION_NAME_FORMAT"
+      <> help ( "Specify a region name format for the result. Must be one " ++
+                "of \"detailed\", \"detailednoqual\", \"full\", " ++
+                "\"namequal\", \"nameonly\", or \"revdetailed\" " ++
+                "(default: full)"
+              )
+    )
+
+-- | Parse a 'RegionType' as a generic command option.
+regionType :: Parser RegionType
+regionType = option (attoReadM parseRegionType)
+    (    long "region-type"
+      <> metavar "REGION_TYPE"
+      <> help ( "Specify a region type (\"country\", \"subnational1\", " ++
+                "\"subnational2\")"
+              )
+    )
+
+-------------------------------------------------------------------------------
+-- Utility functions
+-------------------------------------------------------------------------------
+
+-- | Configurable 'SpeciesCode' option
+speciesCodeOpt :: String -> Parser SpeciesCode
+speciesCodeOpt thing =
+  option (attoReadM parseSpeciesCode)
+    (    long "species-code"
+      <> metavar "SPECIES_CODE"
+      <> help helpStr
+    )
+  where
+    helpStr :: String
+    helpStr =
+      printf
+        ( "Specify a species code to fetch %s for (e.g. \"barswa\" for Barn " ++
+          "Swallow)"
+        )
+        thing
+
+-- | Configurable "search radius" option
+searchRadiusOpt
+  :: String
+  -- ^ What are we searching for (e.g. "observations")
+  -> Integer
+  -- ^ Maximum allowed value by the API
+  -> Maybe Integer
+  -- ^ Default value of the API ('Nothing' for "no limit")
+  -> Parser Integer
+searchRadiusOpt thing maxRadius mDefaultRadius =
+    option (attoReadM A.decimal)
+      (    long "radius"
+        <> metavar "KILOMETERS"
+        <> help helpStr
+      )
+  where
+    helpStr :: String
+    helpStr =
+      printf
+        "Specify the search radius to fetch %s within (0 to %d, default: %s)"
+        thing
+        maxRadius
+        defStr
+
+    defStr = maybe "no limit" show mDefaultRadius
+
+-- | Configurable "back" option
+backOpt
+  :: String
+  -- ^ What are we fetching (e.g. "hotspots")
+  -> String
+  -- ^ Verb we are filtering on (e.g. "submitted", or "visited")
+  -> Maybe Integer
+  -- ^ Default value ('Nothing' for "no limit")
+  -> Parser Integer
+backOpt thing verb mmax =
+    option (attoReadM A.decimal)
+      (    long "back"
+        <> metavar "N"
+        <> help helpStr
+      )
+  where
+    helpStr :: String
+    helpStr =
+      printf
+        "Only fetch %s %s within the last N days (1 - 30, default: %s)"
+        thing
+        verb
+        defStr
+
+    defStr = maybe "no limit" show mmax
+
+-- | Configurable lat/lng option
+latLngOpt
+  :: String
+  -- ^ "latitude" or "longitude"
+  -> String
+  -- ^ What are we looking for
+  -> Parser Double
+latLngOpt latLng thing =
+    option (attoReadM A.double)
+      (    long latLng
+        <> metavar (map toUpper latLng)
+        <> help helpStr
+      )
+  where
+    helpStr :: String
+    helpStr =
+      printf
+        "Specify the %s of the location to fetch %s near"
+        latLng
+        thing
+
+-- Attoparsec utilities
+
+-- | Convert an attoparsec parser into an optparse-applicative parser.
+attoReadM :: A.Parser a -> ReadM a
+attoReadM p = eitherReader (A.parseOnly p . Text.pack)
