diff --git a/ChangeLog.md b/ChangeLog.md
new file mode 100644
--- /dev/null
+++ b/ChangeLog.md
@@ -0,0 +1,6 @@
+# dnf-repo releases
+
+## 0.1 (2022-06-20)
+- initial version with basic functionality: --add-copr, --add-koji,
+  --disable repo, --enable repo, --enable/disable-{testing,modular}
+  and --save & --expire
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2022, 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,73 @@
+# dnf-repo
+
+A wrapper of the  dnf package manager for fine control of enabled/disabled yum repos (eg coprs).
+
+DNF is not terribly fast at handling repos partly because it likes
+to refresh the cached repodata frequently. So it can be advantageous
+to disable smaller repos by default and only enable them as needed.
+
+This tool can temporarily enable/disable repos selected by substring.
+Repo states can also be saved or their cache individually expired.
+
+There are options to enable/disable testing/modular repos,
+and also to create a repo file for a Copr or Koji repo.
+
+## Help
+
+```shellsession
+$ dnf-repo --version
+0.1
+$ dnf-repo --help
+DNF wrapper repo tool
+
+Usage: dnf-repo [--version] [-n|--dryrun] [-D|--debug] [-s|--save]
+                [(-c|--add-copr COPR) | (-k|--add-koji REPO) |
+                  (-d|--disable REPOPAT) | (-e|--enable REPOPAT) |
+                  (-x|--expire REPOPAT) | (-E|--delete-repo REPOPAT)]
+                [(-t|--enable-testing) | (-T|--disable-testing)]
+                [(-m|--enable-modular) | (-M|--disable-modular)] [DNFARGS]
+  see https://github.com/juhp/dnf-repo#readme
+
+Available options:
+  -h,--help                Show this help text
+  --version                Show version
+  -n,--dryrun              Dry run
+  -D,--debug               Debug output
+  -s,--save                Save the repo enable/disable state
+  -c,--add-copr COPR       Create repo file for copr repo
+  -k,--add-koji REPO       Create repo file for koji repo
+  -d,--disable REPOPAT     Disable repos
+  -e,--enable REPOPAT      Enable repos
+  -x,--expire REPOPAT      Expire repo cache
+  -E,--delete-repo REPOPAT Remove unwanted .repo file
+  -t,--enable-testing      Enable testing repos
+  -T,--disable-testing     Disable testing repos
+  -m,--enable-modular      Enable modular repos
+  -M,--disable-modular     Disable modular repos
+```
+
+## Usage
+List repos:
+```shellsession
+$ dnf-repo
+```
+
+Update with --enable-testing:
+```shellsession
+$ sudo dnf-repo -t update
+```
+
+List disabled copr repos:
+```shellsession
+$ dnf-repo -e copr
+```
+
+Disable copr repos for update:
+```shellsession
+$ sudo dnf-repo -d copr update
+```
+
+Disable modular repos permanently:
+```shellsession
+$ sudo dnf-repo --disable-modular --save
+```
diff --git a/data/copr.fedorainfracloud.orgCOLONOWNERCOLONREPO.repo b/data/copr.fedorainfracloud.orgCOLONOWNERCOLONREPO.repo
new file mode 100644
--- /dev/null
+++ b/data/copr.fedorainfracloud.orgCOLONOWNERCOLONREPO.repo
@@ -0,0 +1,10 @@
+[copr:copr.fedorainfracloud.org:@COPR_OWNER@:@COPR_REPO@]
+name=Copr repo for @COPR_REPO@ owned by @COPR_OWNER@
+baseurl=https://download.copr.fedorainfracloud.org/results/@COPR_OWNER@/@COPR_REPO@/fedora-$releasever-$basearch/
+type=rpm-md
+skip_if_unavailable=True
+gpgcheck=1
+gpgkey=https://download.copr.fedorainfracloud.org/results/@COPR_OWNER@/@COPR_REPO@/pubkey.gpg
+repo_gpgcheck=0
+enabled=0
+enabled_metadata=1
diff --git a/data/koji-REPO.repo b/data/koji-REPO.repo
new file mode 100644
--- /dev/null
+++ b/data/koji-REPO.repo
@@ -0,0 +1,10 @@
+[koji-@REPO@]
+name=Fedora - Koji @REPO@
+failovermethod=priority
+baseurl=https://kojipkgs.fedoraproject.org/repos/@REPO@/latest/x86_64/
+enabled=0
+metadata_expire=1h
+repo_gpgcheck=0
+type=rpm
+gpgcheck=0
+skip_if_unavailable=False
diff --git a/dnf-repo.cabal b/dnf-repo.cabal
new file mode 100644
--- /dev/null
+++ b/dnf-repo.cabal
@@ -0,0 +1,60 @@
+name:                dnf-repo
+version:             0.1
+synopsis:            DNF wrapper tool to control repos
+description:
+        A command-line wrapper of the dnf package manager to
+        control the repos enabled for a dnf invocation.
+license:             BSD3
+license-file:        LICENSE
+author:              Jens Petersen <juhpetersen@gmail.com>
+maintainer:          Jens Petersen <juhpetersen@gmail.com>
+copyright:           2022  Jens Petersen <juhpetersen@gmail.com>
+category:            Utility
+homepage:            https://github.com/juhp/dnf-repo
+bug-reports:         https://github.com/juhp/dnf-repo/issues
+build-type:          Simple
+extra-doc-files:     README.md
+                     ChangeLog.md
+cabal-version:       2.0
+tested-with:         GHC == 8.6.5
+                     ||  == 8.8.4
+                     ||  == 8.10.7
+                     ||  == 9.0.2
+                     ||  == 9.2.3
+data-dir:            data
+data-files:          copr.fedorainfracloud.orgCOLONOWNERCOLONREPO.repo
+                     koji-REPO.repo
+
+source-repository head
+  type:                git
+  location:            https://github.com/juhp/dnf-repo.git
+
+executable dnf-repo
+  main-is:             Main.hs
+  other-modules:       Paths_dnf_repo
+                       ExpireRepos
+                       Sudo
+                       YumRepoFile
+  autogen-modules:     Paths_dnf_repo
+  hs-source-dirs:      src
+  build-depends:       base < 5,
+                       directory,
+                       extra,
+                       filepath,
+                       simple-cmd,
+                       simple-cmd-args
+  default-language:    Haskell2010
+  ghc-options:         -Wall
+  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
diff --git a/src/ExpireRepos.hs b/src/ExpireRepos.hs
new file mode 100644
--- /dev/null
+++ b/src/ExpireRepos.hs
@@ -0,0 +1,27 @@
+module ExpireRepos (expireRepos) where
+
+import Data.List (nub)
+import SimpleCmd (error')
+
+import Sudo
+
+expiredFile :: FilePath
+expiredFile = "/var/cache/dnf/expired_repos.json"
+
+expireRepos :: Bool -> [String] -> IO ()
+expireRepos _ [] = error' "no repos to expire given"
+expireRepos dryrun repos = do
+  old <- read <$> readFile expiredFile :: IO [String]
+  let expired = nub $ old ++ repos
+  doSudo dryrun "sed" ["-i", "-e",
+                       "s/" ++ renderShow old ++ "/" ++ renderShow expired ++ "/",
+                       expiredFile]
+  putStrLn $ "expired now: " ++ show expired
+  where
+    renderShow :: [String] -> String
+    renderShow = render . show
+
+    render :: String -> String
+    render "" = ""
+    render (c:cs) =
+      (if c `elem` "[]" then ['\\',c] else [c]) ++ render cs
diff --git a/src/Main.hs b/src/Main.hs
new file mode 100644
--- /dev/null
+++ b/src/Main.hs
@@ -0,0 +1,185 @@
+{-# LANGUAGE CPP #-}
+
+-- SPDX-License-Identifier: BSD-3-Clause
+
+module Main (main) where
+
+import Control.Monad.Extra
+import Data.Bifunctor (bimap)
+import Data.List.Extra
+import Data.Maybe (mapMaybe)
+import Data.Tuple.Extra (fst3,snd3)
+import SimpleCmd
+import SimpleCmdArgs
+import System.Directory
+import System.FilePath
+import System.IO (hSetBuffering, stdout, BufferMode(NoBuffering))
+import System.IO.Extra (withTempDir)
+import System.Time.Extra (sleep)
+
+import Paths_dnf_repo (getDataFileName, version)
+import ExpireRepos (expireRepos)
+import Sudo
+import YumRepoFile
+
+main :: IO ()
+main = do
+  simpleCmdArgs' (Just version)
+    "DNF wrapper repo tool"
+    "see https://github.com/juhp/dnf-repo#readme" $
+    runMain
+    <$> switchWith 'n' "dryrun" "Dry run"
+    <*> switchWith 'D' "debug" "Debug output"
+    <*> switchWith 's' "save" "Save the repo enable/disable state"
+    <*> modeOpt
+    <*> optional testingOpt
+    <*> optional modularOpt
+    <*> many (strArg "DNFARGS")
+  where
+    modeOpt =
+      AddCopr . replace "/" ":" <$> strOptionWith 'c' "add-copr" "COPR" "Create repo file for copr repo" <|>
+      AddKoji <$> strOptionWith 'k' "add-koji" "REPO" "Create repo file for koji repo" <|>
+      DisableRepo <$> strOptionWith 'd' "disable" "REPOPAT" "Disable repos" <|>
+      EnableRepo <$> strOptionWith 'e' "enable" "REPOPAT" "Enable repos" <|>
+      ExpireRepo <$> strOptionWith 'x' "expire" "REPOPAT" "Expire repo cache" <|>
+      DeleteRepo <$> strOptionWith 'E' "delete-repofile" "REPOPAT" "Remove unwanted .repo file" <|>
+      pure Default
+
+    testingOpt =
+      flagWith' EnableTesting 't' "enable-testing" "Enable testing repos" <|>
+      flagWith' DisableTesting 'T' "disable-testing" "Disable testing repos"
+
+    modularOpt =
+      flagWith' EnableModular 'm' "enable-modular" "Enable modular repos" <|>
+      flagWith' DisableModular 'M' "disable-modular" "Disable modular repos"
+
+coprRepoTemplate :: FilePath
+coprRepoTemplate = "copr.fedorainfracloud.orgCOLONOWNERCOLONREPO.repo"
+
+kojiRepoTemplate :: FilePath
+kojiRepoTemplate = "koji-REPO.repo"
+
+-- FIXME both enabling and disabled at the same time
+-- FIXME --enable-all-coprs (for updating etc)
+-- FIXME confirm repos if many
+-- FIXME --disable-non-cores (modular,testing,cisco, etc)
+runMain :: Bool -> Bool -> Bool -> Mode -> Maybe Testing -> Maybe Modular
+        -> [String] -> IO ()
+runMain dryrun debug save mode mtesting mmodular args = do
+  hSetBuffering stdout NoBuffering
+  withCurrentDirectory "/etc/yum.repos.d" $ do
+    case mode of
+      AddCopr copr -> addCoprRepo copr
+      AddKoji repo -> addKojiRepo repo
+      _ -> return ()
+    repofiles <- filesWithExtension "." "repo"
+--    when debug $ print repofiles
+    nameStates <- sort <$> mapM readRepo repofiles
+    let repoActs = mapMaybe (selectRepo debug mode mtesting mmodular) nameStates
+    unless (null repoActs) $ do
+      mapM_ print repoActs
+      putStrLn ""
+    case mode of
+      ExpireRepo _ -> do
+        putStrLn ""
+        expireRepos dryrun $ mapMaybe expiring repoActs
+      DeleteRepo _ ->
+        mapM_ deleteRepos $ mapMaybe deleting repoActs
+      _ -> return ()
+    when save $
+      if null repoActs
+        then putStrLn "no changes to save\n"
+        else do
+        prompt_ "Press Enter to save repo enabled state"
+        doSudo dryrun "dnf" $
+          "config-manager" :
+          concatMap saveRepo repoActs
+    if null args
+      then do
+      listRepos $ map (updateState repoActs) nameStates
+      else do
+      sleep 1
+      putStrLn ""
+      let repoargs = concatMap changeRepo repoActs
+        in doSudo dryrun "dnf" $ repoargs ++ args
+    where
+      -- FIXME pull non-fedora copr repo file
+      -- FIXME delete created copr repo file if repo doesn't exist
+      addCoprRepo :: String -> IO ()
+      addCoprRepo repo = do
+        case stripInfix ":" repo of
+          Nothing -> error' $ "invalid copr: " ++ repo
+          Just (copr_owner,copr_repo) -> do
+            template <- getDataFileName coprRepoTemplate
+            repodef <- cmd "sed" ["-e", "s/@COPR_OWNER@/" ++ copr_owner ++ "/g", "-e", "s/@COPR_REPO@/" ++ copr_repo ++ "/g", template]
+            let repofile = ("_copr:" ++) $
+                           replace "COLON" ":" $
+                           replace "OWNER" copr_owner $
+                           replace "REPO" copr_repo coprRepoTemplate
+            exists <- doesFileExist repofile
+            if exists
+              then error' $ "repo already defined: " ++ repofile
+              else putStrLn $ "Setting up copr repo " ++ repo
+            withTempDir $ \ tmpdir -> do
+              let tmpfile = tmpdir </> repofile
+              unless dryrun $ writeFile tmpfile repodef
+              doSudo dryrun "cp" [tmpfile, repofile]
+
+      addKojiRepo :: String -> IO ()
+      addKojiRepo repo = do
+        template <- getDataFileName kojiRepoTemplate
+        repodef <- cmd "sed" ["-e", "s/@REPO@/" ++ repo ++ "/g", template]
+        let repofile = replace "REPO" repo kojiRepoTemplate
+        exists <- doesFileExist repofile
+        if exists
+          then error' $ "repo already defined: " ++ repofile
+          else putStrLn $ "Setting up koji repo " ++ repo
+        withTempDir $ \ tmpdir -> do
+          let tmpfile = tmpdir </> repofile
+          unless dryrun $ writeFile tmpfile repodef
+          doSudo dryrun "cp" [tmpfile, repofile]
+
+      listRepos :: [RepoState] -> IO ()
+      listRepos repoStates = do
+        let (on,off) =
+              -- can't this be simplified?
+              bimap (map fst3) (map fst3) $ partition snd3 repoStates
+        putStrLn "Enabled:"
+        mapM_ putStrLn on
+        putStrLn ""
+        putStrLn "Disabled:"
+        mapM_ putStrLn off
+
+      deleteRepos :: FilePath -> IO ()
+      deleteRepos repofile = do
+        mowned <- cmdMaybe "rpm" ["-qf", "/etc/yum.repos.d" </> repofile]
+        case mowned of
+          Just owner -> error' $ repofile +-+ "owned by" +-+ owner
+          Nothing -> do
+            ok <- yesno $ "Remove " ++ takeFileName repofile
+            when ok $ doSudo dryrun "rm" [repofile]
+
+#if !MIN_VERSION_simple_cmd(0,2,4)
+filesWithExtension :: FilePath -> String -> IO [FilePath]
+filesWithExtension dir ext =
+  filter (ext `isExtensionOf`) <$> listDirectory dir
+#endif
+
+prompt :: String -> IO String
+prompt desc = do
+  putStr $ desc ++ ": "
+  getLine
+
+prompt_ :: String -> IO ()
+prompt_ desc = do
+  void $ prompt desc
+
+yesno :: String -> IO Bool
+yesno desc = do
+  inp <- prompt $ desc ++ "? [y/n]"
+  case lower inp of
+    "y" -> return True
+    "yes" -> return True
+    "n" -> return False
+    "no" -> return False
+    _ ->  yesno desc
diff --git a/src/Sudo.hs b/src/Sudo.hs
new file mode 100644
--- /dev/null
+++ b/src/Sudo.hs
@@ -0,0 +1,9 @@
+module Sudo (doSudo) where
+
+import SimpleCmd (cmdN, sudo_)
+
+-- FIXME make this silent (simple-cmd-0.2.7) unless debug
+doSudo :: Bool -> String -> [String] -> IO ()
+doSudo dryrun c args = do
+  (if dryrun then cmdN else sudo_) c args
+  putStrLn ""
diff --git a/src/YumRepoFile.hs b/src/YumRepoFile.hs
new file mode 100644
--- /dev/null
+++ b/src/YumRepoFile.hs
@@ -0,0 +1,147 @@
+{-# LANGUAGE OverloadedStrings #-}
+
+module YumRepoFile (
+  Mode(..),
+  readRepo,
+  RepoState,
+  selectRepo,
+  Modular(..),
+  Testing(..),
+  changeRepo,
+  saveRepo,
+  updateState,
+  expiring,
+  deleting
+  )
+where
+
+import Data.List.Extra (isPrefixOf, isInfixOf, isSuffixOf, trim)
+import SimpleCmd (error')
+
+type RepoState = (String,Bool,FilePath)
+
+data Mode = AddCopr String | AddKoji String
+          | EnableRepo String | DisableRepo String
+          | ExpireRepo String | DeleteRepo String | Default
+  deriving Eq
+
+data ChangeEnable = Disable String | Enable String | Expire String
+                  | Delete FilePath
+  deriving (Eq,Ord,Show)
+
+changeRepo :: ChangeEnable -> [String]
+changeRepo (Disable r) = ["--disablerepo", r]
+changeRepo (Enable r) = ["--enablerepo", r]
+changeRepo _ = []
+
+saveRepo :: ChangeEnable -> [String]
+saveRepo (Disable r) = ["--disable", r]
+saveRepo (Enable r) = ["--enable", r]
+saveRepo _ = []
+
+expiring :: ChangeEnable -> Maybe String
+expiring (Expire r) = Just r
+expiring _ = Nothing
+
+deleting :: ChangeEnable -> Maybe String
+deleting (Delete r) = Just r
+deleting _ = Nothing
+
+updateState :: [ChangeEnable] -> RepoState -> RepoState
+updateState [] rs = rs
+updateState (ce:ces) re@(repo,enabled,file) =
+  case ce of
+    Disable r | r == repo && enabled -> (repo,False,file)
+    Enable r | r == repo && not enabled -> (repo,True,file)
+    _ -> updateState ces re
+
+data Modular = EnableModular | DisableModular
+  deriving Eq
+
+data Testing = EnableTesting | DisableTesting
+  deriving Eq
+
+selectRepo :: Bool -> Mode -> Maybe Testing -> Maybe Modular
+           -> RepoState -> Maybe ChangeEnable
+selectRepo _debug mode mtesting mmodular (name,enabled,file) =
+  case mode of
+    AddCopr repo -> if repo `isSuffixOf` name && not enabled
+                    then Just (Enable name)
+                    else selectOther
+    AddKoji repo -> if repo `isSuffixOf` name && not enabled
+                    then Just (Enable name)
+                    else selectOther
+    EnableRepo pat -> if pat `isInfixOf` name && not enabled
+                  then Just (Enable name)
+                  else selectOther
+    DisableRepo pat -> if pat `isInfixOf` name && enabled
+                   then Just (Disable name)
+                   else selectOther
+    ExpireRepo pat -> if pat `isInfixOf` name
+                  then Just (Expire name)
+                  else Nothing
+    DeleteRepo pat -> if pat `isInfixOf` name
+                  then if enabled
+                       then error' $ "disable repo before deleting: " ++ name
+                       else Just (Delete file)
+                  else Nothing
+    _ -> selectOther
+  where
+    selectOther :: Maybe ChangeEnable
+    selectOther
+      | "modular" `isSuffixOf` name =
+        case mmodular of
+          Nothing -> Nothing
+          Just include ->
+            case include of
+              EnableModular | not enabled ->
+                               if "testing" `isInfixOf` name
+                               then if mtesting == Just EnableTesting
+                                    then Just (Enable name)
+                                    else Nothing
+                               else Just (Enable name)
+              DisableModular | enabled -> Just (Disable name)
+              _ -> Nothing
+      | "testing" `isInfixOf` name =
+          case mtesting of
+            Nothing -> Nothing
+            Just include ->
+              case include of
+                EnableTesting | not enabled -> Just (Enable name)
+                DisableTesting | enabled -> Just (Disable name)
+                _ -> Nothing
+      | otherwise = Nothing
+
+readRepo :: FilePath -> IO RepoState
+readRepo file =
+  parseRepo file . lines <$> readFile file
+
+-- was called parseIni
+parseRepo :: FilePath -> [String] -> RepoState
+parseRepo file [] = error' $ "empty ini file: " ++ file
+parseRepo file (l:ls) =
+  case trim l of
+    "" -> parseRepo file ls
+    ('#':_) -> parseRepo file ls
+    sec ->
+      let section = secName sec
+          enabled =
+            case dropWhile (not . ("enabled=" `isPrefixOf`)) ls of
+              [] -> error' $ "no enabled field for " ++ section
+              (e:_) ->
+                case trim e of
+                  "enabled=1" -> True
+                  "enabled=0" -> False
+                  _ -> error' $ "unknown enabled state " ++ e ++ " for " ++ section
+      in (section,enabled,file)
+  where
+    secName :: String -> String
+    secName sec =
+      if ' ' `elem` sec
+      then error' $ "section contains space: " ++ sec
+      else case sec of
+        ('[' : rest) ->
+          if last rest == ']'
+          then init rest
+          else error' $ "bad section " ++ sec ++ " in " ++ file
+        _ -> error' $ "section not found in " ++ file
