diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,15 @@
+# Release history for koji-install
+
+## 0.2.0 (2021-12-03)
+- initial Hackage release
+- `--hub` to select hub
+- `--pkgsurl` to override kojifiles url
+- override `--disttag`
+- select specific build with `--nvr` or `--nv`
+- `--debug` output
+
+## 0.1.0 (2021-08-12)
+- pre-release on copr
+- initial options:
+  --all/--ask/--base-only/--exclude-devel --dry-run
+- only supports Fedora Koji
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2021, Jens Petersen
+
+All rights reserved.
+
+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 Jens Petersen nor the names of other
+      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
+OWNER 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,62 @@
+# koji-install
+
+A CLI tool to download and install rpms from a Koji build.
+
+Koji is a package buildsystem used by Fedora, Centos, and some other projects.
+
+## Usage
+
+By default it uses Fedora Koji.
+
+$ koji-install podman
+
+Will download the latest build for your Fedora version,
+and try to install it.
+
+You can specify a different hub using `--hub`.
+
+### Selecting subpackages
+
+By default only installed subpackages are and downloaded and updated,
+but the following options change the behavior:
+
+`--all`: install all subpackages
+
+`--ask`: ask about each subpackage
+
+`--base-only`: only install base package
+
+`--exclude-devel`: skip devel subpackages
+
+### Help
+```shellsession
+$ koji-install
+Install latest build from Koji
+
+Usage: koji-install [--version] [-n|--dry-run] [-D|--debug] [-H|--hub HUB]
+                    [-P|--packages-url URL]
+                    [(-a|--all) | (-A|--ask) | (-b|--base-only) |
+                      (-D|--exclude-devel)] [-d|--disttag DISTTAG]
+                    [(-R|--nvr) | (-V|--nv)] PACKAGE
+  Download and install latest package build from Koji tag.
+  HUB = fedora, stream, mbox, rpmfusion, URL
+
+Available options:
+  -h,--help                Show this help text
+  --version                Show version
+  -n,--dry-run             Don't actually download anything
+  -D,--debug               More detailed output
+  -H,--hub HUB             KojiHub shortname or url [default: fedora]
+  -P,--packages-url URL    KojiFiles packages url [default: fedora]
+  -a,--all                 all subpackages
+  -A,--ask                 ask for each subpackge
+  -b,--base-only           only base package
+  -D,--exclude-devel       Skip devel packages
+  -d,--disttag DISTTAG     Use a different disttag [default: .fc35]
+  -R,--nvr                 Give an N-V-R instead of package name
+  -V,--nv                  Give an N-V instead of package name
+```
+
+## Installation
+
+`cabal-rpm builddep && cabal install` or `stack install`.
diff --git a/koji-install.cabal b/koji-install.cabal
new file mode 100644
--- /dev/null
+++ b/koji-install.cabal
@@ -0,0 +1,71 @@
+name:                koji-install
+version:             0.2.0
+synopsis:            CLI tool for installing rpms directly from Fedora Koji
+description:
+        koji-install can install the latest koji build of a package locally.
+        By default it only downloads newer binaries of the subpackages
+        already installed, but there are options to override that.
+license:             BSD3
+license-file:        LICENSE
+author:              Jens Petersen <juhpetersen@gmail.com>
+maintainer:          Jens Petersen <juhpetersen@gmail.com>
+copyright:           2021  Jens Petersen <juhpetersen@gmail.com>
+category:            Utility
+homepage:            https://github.com/juhp/koji-install
+bug-reports:         https://github.com/juhp/koji-install/issues
+build-type:          Simple
+extra-doc-files:     README.md
+                     ChangeLog.md
+cabal-version:       1.18
+tested-with:         GHC== 8.4.4
+                     || == 8.6.5
+                     || == 8.8.4
+                     || == 8.10.5
+                     || == 9.0.1
+
+source-repository head
+  type:                git
+  location:            https://github.com/juhp/koji-install.git
+
+executable koji-install
+  main-is:             Main.hs
+  other-modules:       Paths_koji_install
+                       DownloadDir
+  hs-source-dirs:      src
+  build-depends:       base < 5,
+                       directory,
+                       extra,
+                       filepath,
+                       koji >= 0.0.2,
+                       optparse-applicative,
+                       rpm-nvr,
+                       simple-cmd,
+                       simple-cmd-args,
+                       xdg-userdirs
+  default-language:    Haskell2010
+  ghc-options:         -Wall -threaded
+  if impl(ghc >= 8.0)
+    ghc-options:       -Wcompat
+                       -Widentities
+                       -Wincomplete-uni-patterns
+                       -Wincomplete-record-updates
+                       -Wredundant-constraints
+  if impl(ghc >= 8.2)
+    ghc-options:       -fhide-source-paths
+  if impl(ghc >= 8.4)
+    ghc-options:       -Wmissing-export-lists
+                       -Wpartial-fields
+  if impl(ghc >= 8.10)
+    ghc-options:       -Wunused-packages
+
+test-suite test
+    main-is: tests.hs
+    type: exitcode-stdio-1.0
+    hs-source-dirs: test
+
+    default-language: Haskell2010
+
+    ghc-options:   -Wall
+    build-depends: base >= 4 && < 5
+                 , simple-cmd
+    build-tools:   koji-install
diff --git a/src/DownloadDir.hs b/src/DownloadDir.hs
new file mode 100644
--- /dev/null
+++ b/src/DownloadDir.hs
@@ -0,0 +1,43 @@
+module DownloadDir (
+  setDownloadDir)
+where
+
+import Control.Monad
+import SimpleCmd (error')
+import System.Directory (createDirectoryIfMissing,
+                         doesDirectoryExist, getHomeDirectory,
+                         setCurrentDirectory)
+import System.Environment.XDG.UserDir (getUserDir)
+import System.FilePath
+
+-- FIXME check writeable
+setDownloadDir :: Bool -> String -> IO FilePath
+setDownloadDir dryrun subdir = do
+  home <- getHomeDirectory
+  dlDir <- getUserDir "DOWNLOAD"
+  dirExists <- doesDirectoryExist dlDir
+  -- is this really necessary?
+  unless (dryrun || dirExists) $
+    when (home == dlDir) $
+      error' "HOME directory does not exist!"
+  let filesDir = dlDir </> subdir
+  filesExists <- doesDirectoryExist filesDir
+  dir <-
+    if filesExists
+    then setCWD filesDir
+    else
+    if dirExists
+      then setCWD dlDir
+      else do
+      if dryrun
+        then return dlDir
+        else do
+        createDirectoryIfMissing True dlDir
+        setCWD dlDir
+  let path = makeRelative home dir
+  return $ if isRelative path then "~" </> path else path
+  where
+    setCWD :: FilePath -> IO FilePath
+    setCWD dir = do
+      setCurrentDirectory dir
+      return dir
diff --git a/src/Main.hs b/src/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Main.hs
@@ -0,0 +1,239 @@
+-- SPDX-License-Identifier: BSD-3-Clause
+
+module Main (main) where
+
+import Control.Monad.Extra
+import Data.Char
+import Data.List.Extra
+import Data.Maybe
+import Data.RPM
+import Distribution.Koji
+import qualified Distribution.Koji.API as Koji
+import Options.Applicative (fullDesc, header, progDescDoc)
+import qualified Options.Applicative.Help.Pretty as P
+import SimpleCmd
+import SimpleCmdArgs
+import System.Directory
+import System.FilePath ((<.>))
+import System.IO
+
+import DownloadDir
+import Paths_koji_install (version)
+
+data InstallMode = Update | All | Ask | Base | NoDevel
+
+data Request = ReqName | ReqNV | ReqNVR
+
+-- FIXME --include devel, --exclude *
+-- FIXME specify tag or task
+-- FIXME support enterprise builds
+-- FIXME --arch (including src)
+-- FIXME --debuginfo
+-- FIXME --delete after installing
+
+main :: IO ()
+main = do
+  sysdisttag <- cmd "rpm" ["--eval", "%{dist}"]
+  let pdoc = Just $ P.vcat
+             [ P.text "Download and install latest package build from Koji tag.",
+               P.text ("HUB = " <> intercalate ", " knownHubs)
+             ]
+  simpleCmdArgsWithMods (Just Paths_koji_install.version)
+    (fullDesc <> header "Install latest build from Koji" <> progDescDoc pdoc) $
+    program
+    <$> switchWith 'n' "dry-run" "Don't actually download anything"
+    <*> switchWith 'D' "debug" "More detailed output"
+    <*> optional (strOptionWith 'H' "hub" "HUB"
+                  "KojiHub shortname or url [default: fedora]")
+    <*> optional (strOptionWith 'P' "packages-url" "URL"
+                  "KojiFiles packages url [default: fedora]")
+    <*> modeOpt
+    <*> disttagOpt sysdisttag
+    <*> (flagWith' ReqNVR 'R' "nvr" "Give an N-V-R instead of package name"
+         <|> flagWith ReqName ReqNVR 'V' "nv" "Give an N-V instead of package name")
+    <*> some (strArg "PACKAGE")
+  where
+    modeOpt :: Parser InstallMode
+    modeOpt =
+      flagWith' All 'a' "all" "all subpackages" <|>
+      flagWith' Ask 'A' "ask" "ask for each subpackge" <|>
+      flagWith' Base 'b' "base-only" "only base package" <|>
+      flagWith' NoDevel 'D' "exclude-devel" "Skip devel packages" <|>
+      pure Update
+
+    disttagOpt :: String -> Parser String
+    disttagOpt disttag = startingDot <$> strOptionalWith 'd' "disttag" "DISTTAG" ("Use a different disttag [default: " ++ disttag ++ "]") disttag
+
+    startingDot cs =
+      case cs of
+        "" -> error' "empty disttag"
+        (c:_) -> if c == '.' then cs else '.' : cs
+
+knownHubs :: [String]
+knownHubs = ["fedora","stream","mbox","rpmfusion", "URL"]
+
+hubURL :: String -> String
+hubURL "fedora" = fedoraKojiHub
+-- later use centosKojiHub
+hubURL "stream" = "https://kojihub.stream.centos.org/kojihub"
+hubURL "mbox" = "https://koji.mbox.centos.org/kojihub"
+hubURL "rpmfusion" = "https://koji.rpmfusion.org/kojihub"
+hubURL "fusion" = "https://koji.rpmfusion.org/kojihub"
+hubURL hub =
+  if "http" `isPrefixOf` hub
+  then hub
+  else error' $ "unknown hub: try " ++ show knownHubs
+
+defaultPkgsURL :: Maybe String -> String
+defaultPkgsURL Nothing =
+  "https://kojipkgs.fedoraproject.org/packages"
+defaultPkgsURL (Just url) =
+  case url of
+    "https://kojihub.stream.centos.org/kojihub" ->
+      "https://kojihub.stream.centos.org/kojifiles/packages"
+    _ ->
+      if "kojihub" `isSuffixOf` url
+      then replace "kojihub" "kojifiles" url
+      else error' "use --files-url to specify kojifiles url for this kojihub"
+
+program :: Bool -> Bool -> Maybe String -> Maybe String -> InstallMode
+        -> String -> Request -> [String] -> IO ()
+program dryrun debug mhuburl mpkgsurl mode disttag request pkgs = do
+  let huburl = maybe fedoraKojiHub hubURL mhuburl
+      pkgsurl = fromMaybe (defaultPkgsURL mhuburl) mpkgsurl
+  when debug $ do
+    putStrLn huburl
+    putStrLn pkgsurl
+  -- FIXME use this?
+  dlDir <- setDownloadDir dryrun "rpms"
+  when debug $ putStrLn dlDir
+  setNoBuffering
+  mapM (kojiLatestRPMs huburl pkgsurl dlDir) pkgs
+    >>= installRPMs dryrun . mconcat
+  where
+    kojiLatestRPMs :: String -> String -> String -> String -> IO [String]
+    kojiLatestRPMs huburl pkgsurl dlDir pkg = do
+      mnvr <- kojiLatestOSBuild huburl disttag request pkg
+      case mnvr of
+        Nothing -> error' $ "latest " ++ pkg ++ " not found"
+        Just nvr -> do
+          putStrLn $ nvr ++ "\n"
+          allRpms <- map (<.> "rpm") . sort . filter (not . debugPkg) <$> kojiGetBuildRPMs huburl nvr
+          dlRpms <- decideRpms mode allRpms
+          unless (dryrun || null dlRpms) $ do
+            mapM_ (downloadRpm pkgsurl (readNVR nvr)) dlRpms
+            -- FIXME once we check file size - can skip if no downloads
+            putStrLn $ "Packages downloaded to " ++ dlDir
+          return dlRpms
+      where
+        decideRpms :: InstallMode -> [String] -> IO [String]
+        decideRpms mode' allRpms =
+          case mode' of
+            All -> return allRpms
+            Ask -> mapMaybeM rpmPrompt allRpms
+            Base -> return $ pure $ minimumOn length $ filter (pkg `isPrefixOf`) allRpms
+            Update -> do
+              rpms <- filterM (isInstalled . rpmName . readNVRA) allRpms
+              if null rpms
+                then decideRpms Ask allRpms
+                else return rpms
+            NoDevel -> return $ filter (not . ("-devel-" `isInfixOf`)) allRpms
+
+    rpmPrompt :: String -> IO (Maybe String)
+    rpmPrompt rpm = do
+      putStr $ rpm ++ " [y/n]: "
+      c <- getChar
+      putStrLn ""
+      case toLower c of
+        'y' -> return $ Just rpm
+        'n' -> return Nothing
+        _ -> rpmPrompt rpm
+
+    isInstalled :: String -> IO Bool
+    isInstalled rpm = cmdBool "rpm" ["--quiet", "-q", rpm]
+
+    debugPkg :: String -> Bool
+    debugPkg p = "-debuginfo-" `isInfixOf` p || "-debugsource-" `isInfixOf` p
+
+kojiLatestOSBuild :: String -> String -> Request -> String -> IO (Maybe String)
+kojiLatestOSBuild hub disttag request pkgpat = do
+  let (pkg,full) = packageOfPattern pkgpat
+  mpkgid <- Koji.getPackageID hub pkg
+  case mpkgid of
+    Nothing -> error $ "package not found: " ++ pkg
+    Just pkgid -> do
+      let opts = [("packageID", ValueInt pkgid),
+                  ("state", ValueInt (fromEnum BuildComplete)),
+                  ("queryOpts",ValueStruct [("limit",ValueInt 1),
+                                            ("order",ValueString "-build_id")])]
+      res <- Koji.listBuilds hub $
+             ("pattern", ValueString (if full then pkgpat else dropSuffix "*" pkgpat ++ "*" ++ disttag)) : opts
+      case res of
+        [] -> return Nothing
+        [bld] -> return $ lookupStruct "nvr" bld
+        _ -> error $ "more than one latest build found for " ++ pkg
+  where
+    packageOfPattern :: String -> (String, Bool)
+    packageOfPattern pat =
+      case request of
+        ReqName -> (dropSuffix "-" $ takeWhile (/= '*') pat, False)
+        ReqNV ->
+          case readNV pat of
+            NV n _ -> (n, False)
+        ReqNVR ->
+          case readNVR pat of
+            NVR n _ -> (n, True)
+
+kojiGetBuildRPMs :: String -> String -> IO [String]
+kojiGetBuildRPMs huburl nvr = do
+  mbid <- kojiGetBuildID huburl nvr
+  case mbid of
+    Nothing -> error $ "Build id not found for " ++ nvr
+    Just (BuildId bid) -> do
+      rpms <- Koji.listBuildRPMs huburl bid
+      return $ map getNVRA $ filter (forArch "x86_64") rpms
+   where
+     forArch :: String -> Struct -> Bool
+     forArch sysarch st =
+       case lookupStruct "arch" st of
+         Just arch -> arch `elem` [sysarch, "noarch"]
+         Nothing -> error $ "No arch found for rpm for: " ++ nvr
+
+     getNVRA :: Struct -> String
+     getNVRA st =
+       case lookupStruct "nvr" st of
+         Nothing -> error' "NVR not found"
+         Just pnvr ->
+           case lookupStruct "arch" st of
+             Nothing -> error "arch not found"
+             Just arch ->
+               pnvr <.> arch
+
+setNoBuffering :: IO ()
+setNoBuffering = do
+  hSetBuffering stdin NoBuffering
+  hSetBuffering stdout NoBuffering
+
+installRPMs :: Bool -> [FilePath] -> IO ()
+installRPMs _ [] = return ()
+installRPMs dryrun pkgs =
+  unless dryrun $
+  sudo_ "dnf" ("install" : map ("./" ++) pkgs)
+
+-- FIXME check file size
+downloadRpm :: String -> NVR -> String -> IO ()
+downloadRpm pkgsurl (NVR n (VerRel v r)) rpm = do
+  unlessM (doesFileExist rpm) $ do
+    let arch = rpmArch (readNVRA rpm)
+        url = pkgsurl +/+ n  +/+ v +/+ r +/+ arch +/+ rpm
+    putStrLn $ "Downloading " ++ rpm
+    cmd_ "curl" ["--fail", "--silent", "-C-", "--show-error", "--remote-name", url]
+
+-- from next http-directory or http-query
+infixr 5 +/+
+(+/+) :: String -> String -> String
+"" +/+ s = s
+s +/+ "" = s
+s +/+ t | last s == '/' = init s +/+ t
+        | head t == '/' = s +/+ tail t
+s +/+ t = s ++ "/" ++ t
diff --git a/test/tests.hs b/test/tests.hs
new file mode 100644
--- /dev/null
+++ b/test/tests.hs
@@ -0,0 +1,20 @@
+import SimpleCmd
+import System.IO
+
+program :: [String] -> IO ()
+program args =
+  putStrLn "" >> cmdLog "koji-install" ("-n" : "-b" : args)
+
+tests :: [[String]]
+tests =
+  [["podman"]
+  ,["-H", "https://kojihub.stream.centos.org/kojihub", "-d", "el9", "bash"]
+  ,["-H", "stream", "-d", "el9", "kernel"]
+--  ,["-H", "rpmfusion", "ffmpeg"]
+  ]
+
+main :: IO ()
+main = do
+  hSetBuffering stdout NoBuffering
+  mapM_ program tests
+  putStrLn $ show (length tests) ++ " tests run"
