diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.1
+
+* Initial release.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,29 @@
+Copyright (c) 2024, Bodigrim
+
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+
+    * Neither the name of the copyright holder nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+# hackage-revdeps [![Hackage](http://img.shields.io/hackage/v/hackage-revdeps.svg)](https://hackage.haskell.org/package/hackage-revdeps) [![Stackage LTS](http://stackage.org/package/hackage-revdeps/badge/lts)](http://stackage.org/lts/package/hackage-revdeps) [![Stackage Nightly](http://stackage.org/package/hackage-revdeps/badge/nightly)](http://stackage.org/nightly/package/hackage-revdeps)
+
+Command-line tool to list Hackage reverse dependencies.
+
+It is different from how Hackage itself tracks them: this tool accounts for all package components, including tests and benchmarks, and counts dependencies only across the latest releases. The approach is roughly equivalent to what [packdeps.haskellers.com](https://packdeps.haskellers.com) used to do.
diff --git a/app/History.hs b/app/History.hs
new file mode 100644
--- /dev/null
+++ b/app/History.hs
@@ -0,0 +1,93 @@
+{-# LANGUAGE ApplicativeDo #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Main (
+  main,
+) where
+
+import Cabal.Config (cfgRepoIndex, hackageHaskellOrg, readConfig)
+import Data.ByteString.Char8 qualified as B
+import Data.Foldable (for_)
+import Data.List qualified as L
+import Data.List.NonEmpty (NonEmpty)
+import Data.List.NonEmpty qualified as NE
+import Data.Map.Strict qualified as M
+import Data.Time (Day, UTCTime (..), addDays, getCurrentTime)
+import Distribution.Types.PackageName (PackageName, unPackageName)
+import Hackage.RevDeps (extractDependencies, latestReleases)
+import Options.Applicative (Parser, auto, execParser, fullDesc, help, helper, info, long, metavar, option, progDesc, showDefault, strArgument, value)
+import Options.Applicative.NonEmpty (some1)
+import System.Console.ANSI (hSupportsANSI, hyperlinkCode)
+import System.Exit (die)
+import System.IO (stdout)
+
+data Config = Config
+  { cnfStart :: !Day
+  , cnfFinish :: !Day
+  , cnfStep :: !Word
+  , cnfPackageNames :: !(NonEmpty PackageName)
+  }
+
+parseArgs :: Day -> Parser Config
+parseArgs today = do
+  cnfStart <-
+    option auto $
+      long "start"
+        <> help "Start date, YYYY-MM-DD"
+        <> value (read "2006-09-01")
+        <> showDefault
+  cnfFinish <-
+    option auto $
+      long "finish"
+        <> help "Finish date, YYYY-MM-DD"
+        <> value today
+        <> showDefault
+  cnfStep <-
+    option auto $
+      long "step"
+        <> help "Step in days"
+        <> value 1000
+        <> showDefault
+  cnfPackageNames <-
+    some1 $
+      strArgument $
+        metavar "PKGS"
+          <> help "Package names to scan Hackage for their reverse dependencies"
+  pure Config {..}
+
+main :: IO ()
+main = do
+  let desc = "Count Hackage reverse dependencies for given dates, using local package index. Consider running 'cabal update' beforehand."
+  today <- utctDay <$> getCurrentTime
+  Config {..} <-
+    execParser $
+      info (helper <*> parseArgs today) (fullDesc <> progDesc desc)
+  let args = NE.toList cnfPackageNames
+      dates = L.nub $ [cnfStart, addDays (fromIntegral cnfStep) cnfStart .. cnfFinish] ++ [cnfFinish]
+  supportsAnsi <- hSupportsANSI stdout
+
+  cnf <- readConfig
+  case cfgRepoIndex cnf hackageHaskellOrg of
+    Nothing -> die $ hackageHaskellOrg ++ " not found in cabal.config, aborting"
+    Just idx -> do
+      putStrLn $ unwords $ "Date      " : map (showPackage supportsAnsi) args
+      let needles = map (B.pack . unPackageName) args
+      for_ dates $ \date -> do
+        releases <- latestReleases needles idx (Just $ UTCTime date 0)
+        let pkgs = fmap (extractDependencies args) releases
+            pkgs' = M.mapWithKey (\k v -> M.delete k v) pkgs
+            counters = M.unionsWith (+) $ fmap (fmap (const (1 :: Int))) pkgs'
+        putStrLn $ unwords $ show date : map (\pkg -> showPair (pkg, M.findWithDefault 0 pkg counters)) args
+
+showPair :: Show v => (PackageName, v) -> String
+showPair (k, v) =
+  replicate (length (unPackageName k) - length (show v)) ' '
+    ++ show v
+
+showPackage :: Bool -> PackageName -> String
+showPackage supportsAnsi p =
+  if supportsAnsi
+    then hyperlinkCode ("https://" ++ hackageHaskellOrg ++ "/package/" ++ xs) xs
+    else xs
+  where
+    xs = unPackageName p
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,91 @@
+{-# LANGUAGE ApplicativeDo #-}
+{-# LANGUAGE RecordWildCards #-}
+
+module Main (
+  main,
+) where
+
+import Cabal.Config (cfgRepoIndex, hackageHaskellOrg, readConfig)
+import Data.ByteString.Char8 qualified as B
+import Data.List.NonEmpty (NonEmpty)
+import Data.List.NonEmpty qualified as NE
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as M
+import Data.Time (Day, UTCTime (..))
+import Distribution.Compat.NonEmptySet (singleton)
+import Distribution.Pretty (pretty)
+import Distribution.Types.Dependency (Dependency (..))
+import Distribution.Types.LibraryName (LibraryName (..))
+import Distribution.Types.PackageName (PackageName, unPackageName)
+import Distribution.Types.VersionRange (VersionRange)
+import Hackage.RevDeps (extractDependencies, latestReleases)
+import Options.Applicative (Parser, ReadM, auto, execParser, fullDesc, help, helper, info, long, metavar, option, optional, progDesc, strArgument)
+import Options.Applicative.NonEmpty (some1)
+import System.Console.ANSI (hSupportsANSI, hyperlinkCode)
+import System.Exit (die)
+import System.IO (stdout)
+
+data Config = Config
+  { cnfIndexState :: !(Maybe UTCTime)
+  , cnfPackageNames :: !(NonEmpty PackageName)
+  }
+
+parseArgs :: Parser Config
+parseArgs = do
+  cnfIndexState <-
+    optional $
+      option (fmap (\d -> UTCTime d 0) (auto :: ReadM Day)) $
+        long "index-state"
+          <> help "Timestamp of index state at which to stop scanning, YYYY-MM-DD"
+  cnfPackageNames <-
+    some1 $
+      strArgument $
+        metavar "PKGS"
+          <> help "Package names to scan Hackage for their reverse dependencies"
+  pure Config {..}
+
+main :: IO ()
+main = do
+  let desc = "List Hackage reverse dependencies, using local package index. Consider running 'cabal update' beforehand."
+  Config {..} <-
+    execParser $
+      info (helper <*> parseArgs) (fullDesc <> progDesc desc)
+  let args = NE.toList cnfPackageNames
+
+  cnf <- readConfig
+  case cfgRepoIndex cnf hackageHaskellOrg of
+    Nothing -> die $ hackageHaskellOrg ++ " not found in cabal.config, aborting"
+    Just idx -> do
+      let needles = map (B.pack . unPackageName) args
+      releases <- latestReleases needles idx cnfIndexState
+      let pkgs = fmap (extractDependencies args) releases
+          pkgs' = M.mapWithKey (\k v -> M.delete k v) pkgs
+      report $ M.filter (not . null) pkgs'
+
+report :: Map PackageName (Map PackageName VersionRange) -> IO ()
+report pkgs = do
+  supportsAnsi <- hSupportsANSI stdout
+  putStrLn "Reverse dependencies:"
+  let prettify (k, v) = pretty $ Dependency k v (singleton LMainLibName)
+      pkgs' = fmap (map prettify . M.toAscList) pkgs
+  reportTable supportsAnsi pkgs'
+  putStrLn "Total count:"
+  let counters = M.unionsWith (+) $ fmap (fmap (const (1 :: Int))) pkgs
+  reportTable supportsAnsi counters
+
+reportTable :: Show v => Bool -> Map PackageName v -> IO ()
+reportTable supportsAnsi kvs = putStrLn $ unlines $ map showPair $ M.toAscList kvs
+  where
+    longestKey = maximum $ 0 : map (length . unPackageName) (M.keys kvs)
+    showPair (k, v) =
+      showPackage supportsAnsi k
+        ++ replicate (longestKey + 1 - length (unPackageName k)) ' '
+        ++ show v
+
+showPackage :: Bool -> PackageName -> String
+showPackage supportsAnsi p =
+  if supportsAnsi
+    then hyperlinkCode ("https://" ++ hackageHaskellOrg ++ "/package/" ++ xs) xs
+    else xs
+  where
+    xs = unPackageName p
diff --git a/hackage-revdeps.cabal b/hackage-revdeps.cabal
new file mode 100644
--- /dev/null
+++ b/hackage-revdeps.cabal
@@ -0,0 +1,95 @@
+cabal-version:   2.2
+name:            hackage-revdeps
+version:         0.1
+license:         BSD-3-Clause
+license-file:    LICENSE
+maintainer:      andrew.lelechenko@gmail.com
+author:          Bodigrim
+tested-with:
+    ghc ==9.12.1 ghc ==9.10.1 ghc ==9.8.4 ghc ==9.6.6 ghc ==9.4.8
+    ghc ==9.2.8
+
+synopsis:        List Hackage reverse dependencies
+description:
+    Command-line tool to list Hackage reverse dependencies.
+    It is different from how Hackage itself tracks them:
+    this tool accounts for all package components, including
+    tests and benchmarks, and counts dependencies only
+    across the latest releases. The approach is roughly
+    equivalent to what <https://packdeps.haskellers.com> used to do.
+
+category:        Development
+build-type:      Simple
+extra-doc-files:
+    CHANGELOG.md
+    README.md
+
+source-repository head
+    type:     git
+    location: https://github.com/Bodigrim/hackage-revdeps.git
+
+flag cabal-syntax
+    default: False
+
+library
+    exposed-modules:  Hackage.RevDeps
+    hs-source-dirs:   src
+    default-language: GHC2021
+    ghc-options:      -Wall -Wunused-packages
+    build-depends:
+        alfred-margaret >=2.0 && <2.2,
+        base >=4.16 && <5,
+        bytestring <0.13,
+        containers <0.8,
+        filepath <1.6,
+        tar <0.7,
+        text >=2.0 && <2.2,
+        time <1.15
+
+    if flag(cabal-syntax)
+        build-depends: Cabal-syntax >=3.8 && <3.15
+
+    else
+        build-depends: Cabal <3.7
+
+executable hackage-revdeps
+    main-is:          Main.hs
+    hs-source-dirs:   app
+    default-language: GHC2021
+    ghc-options:      -Wall -Wunused-packages
+    build-depends:
+        base,
+        ansi-terminal >=0.11.3 && <1.2,
+        bytestring,
+        cabal-install-parsers <0.7,
+        containers,
+        hackage-revdeps,
+        optparse-applicative >=0.16 && <0.19,
+        time <1.15
+
+    if flag(cabal-syntax)
+        build-depends: Cabal-syntax >=3.8 && <3.15
+
+    else
+        build-depends: Cabal <3.7
+
+executable hackage-revdeps-history
+    main-is:          History.hs
+    hs-source-dirs:   app
+    default-language: GHC2021
+    ghc-options:      -Wall -Wunused-packages
+    build-depends:
+        base,
+        ansi-terminal >=0.11.3 && <1.2,
+        bytestring,
+        cabal-install-parsers <0.7,
+        containers,
+        hackage-revdeps,
+        optparse-applicative >=0.16 && <0.19,
+        time <1.15
+
+    if flag(cabal-syntax)
+        build-depends: Cabal-syntax >=3.8 && <3.15
+
+    else
+        build-depends: Cabal <3.7
diff --git a/src/Hackage/RevDeps.hs b/src/Hackage/RevDeps.hs
new file mode 100644
--- /dev/null
+++ b/src/Hackage/RevDeps.hs
@@ -0,0 +1,146 @@
+-- | Functions to list Hackage reverse dependencies.
+module Hackage.RevDeps (
+  latestReleases,
+  extractDependencies,
+) where
+
+import Codec.Archive.Tar qualified as Tar
+import Codec.Archive.Tar.Entry qualified as Tar
+import Control.Exception (throwIO)
+import Data.ByteString (ByteString)
+import Data.ByteString.Lazy qualified as BL
+import Data.Char (isPunctuation, isSpace)
+import Data.List (isSuffixOf)
+import Data.Map.Strict (Map)
+import Data.Map.Strict qualified as M
+import Data.Maybe (mapMaybe)
+import Data.Text (Text)
+import Data.Text qualified as T
+import Data.Text.AhoCorasick.Automaton qualified as Aho
+import Data.Text.Encoding (decodeUtf8Lenient)
+import Data.Text.Unsafe qualified as T
+import Data.Time (UTCTime)
+import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
+import Distribution.Compat.Lens (toListOf)
+import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe)
+import Distribution.Types.BuildInfo (targetBuildDepends)
+import Distribution.Types.BuildInfo.Lens qualified as Lens
+import Distribution.Types.Dependency (Dependency (..))
+import Distribution.Types.PackageName (PackageName, mkPackageName)
+import Distribution.Types.VersionRange (VersionRange)
+import Distribution.Version (intersectVersionRanges, simplifyVersionRange)
+import System.FilePath (isPathSeparator)
+
+-- | Scan Cabal index @01-index.tar@ and return Cabal files
+-- of latest releases (not necessarily largest versions), which
+-- contain one of the needles as an entire word (separated by spaces
+-- or punctuation).
+--
+-- To avoid ambiguity: we first select the latest releases,
+-- then filter them by needles.
+latestReleases
+  :: [ByteString]
+  -- ^ Needles to search in Cabal files.
+  -> FilePath
+  -- ^ Path to @01-index.tar@.
+  -- One can use @Cabal.Config.cfgRepoIndex@ from @cabal-install-parsers@
+  -- to obtain it.
+  -> Maybe UTCTime
+  -- ^ Timestamp of index state at which to stop scanning.
+  -> IO (Map PackageName ByteString)
+  -- ^ Map from latest releases to their Cabal files.
+latestReleases needles idx indexState =
+  M.filter (containsAnyAsWholeWord machine . decodeUtf8Lenient)
+    <$> getLatestReleases idx indexState
+  where
+    machine = Aho.build (map ((\x -> (x, x)) . decodeUtf8Lenient) needles)
+
+-- | Strip revisions and releases except the latest one.
+getLatestReleases
+  :: FilePath
+  -> Maybe UTCTime
+  -> IO (Map PackageName ByteString)
+getLatestReleases idx indexState = foldCabalFilesInIndex idx indexState mempty M.insert
+
+containsAnyAsWholeWord :: Aho.AcMachine Text -> Text -> Bool
+containsAnyAsWholeWord machine hay = Aho.runText False go machine hay
+  where
+    isWordBoundary c = isSpace c || isPunctuation c
+
+    go :: Bool -> Aho.Match Text -> Aho.Next Bool
+    go _ (Aho.Match pos val) =
+      if startsWithBoundary && endsWithBoundary
+        then Aho.Done True
+        else Aho.Step False
+      where
+        pref =
+          T.dropEnd (T.length val) $
+            T.takeWord8 (fromIntegral (Aho.codeUnitIndex pos)) hay
+        startsWithBoundary = maybe True (isWordBoundary . snd) (T.unsnoc pref)
+        suff = T.dropWord8 (fromIntegral (Aho.codeUnitIndex pos)) hay
+        endsWithBoundary = maybe True (isWordBoundary . fst) (T.uncons suff)
+
+-- | Inspired by @Cabal.Index.foldIndex@ from @cabal-install-parsers@.
+foldCabalFilesInIndex
+  :: FilePath
+  -> Maybe UTCTime
+  -> a
+  -> (PackageName -> ByteString -> a -> a)
+  -> IO a
+foldCabalFilesInIndex fp indexState ini action = do
+  contents <- BL.readFile fp
+  let entries' = Tar.read contents
+      entries = case indexState of
+        Nothing -> entries'
+        Just t ->
+          tarTakeWhile
+            (\e -> fromIntegral (Tar.entryTime e) <= utcTimeToPOSIXSeconds t)
+            entries'
+  case Tar.foldlEntries go ini entries of
+    Left (err, _) -> throwIO err
+    Right res -> pure res
+  where
+    go acc entry =
+      case Tar.entryContent entry of
+        Tar.NormalFile contents _ ->
+          if isCabalFile then action pkgName bs acc else acc
+          where
+            bs = BL.toStrict contents
+            fpath = Tar.entryPath entry
+            isCabalFile = ".cabal" `isSuffixOf` fpath
+            pkgName = mkPackageName $ takeWhile (not . isPathSeparator) fpath
+        _ -> acc
+
+tarTakeWhile
+  :: (Tar.Entry -> Bool)
+  -> Tar.Entries a
+  -> Tar.Entries a
+tarTakeWhile p =
+  Tar.foldEntries
+    (\x xs -> if p x then Tar.Next x xs else Tar.Done)
+    Tar.Done
+    Tar.Fail
+
+-- | Scan Cabal file looking for package names,
+-- coalescing version bounds from all components and under all conditions.
+extractDependencies
+  :: [PackageName]
+  -- ^ Needles to search.
+  -> ByteString
+  -- ^ Content of a Cabal file.
+  -> Map PackageName VersionRange
+  -- ^ Needles found in the Cabal file and their version bounds.
+extractDependencies needles = relevantDeps needles . extractDeps
+
+extractDeps :: ByteString -> [Dependency]
+extractDeps cnt = case parseGenericPackageDescriptionMaybe cnt of
+  Nothing -> mempty
+  Just descr -> foldMap targetBuildDepends $ toListOf Lens.traverseBuildInfos descr
+
+relevantDeps :: [PackageName] -> [Dependency] -> Map PackageName VersionRange
+relevantDeps needles =
+  fmap simplifyVersionRange . M.fromListWith intersectVersionRanges . mapMaybe go
+  where
+    go (Dependency pkg ver _)
+      | pkg `elem` needles = Just (pkg, ver)
+      | otherwise = Nothing
