diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,5 @@
+## [_Unreleased_](https://github.com/freckle/freckle-app/compare/freckle-env-v0.0.0.0...main)
+
+## [v0.0.0.0](https://github.com/freckle/freckle-app/tree/freckle-env-v0.0.0.0/freckle-env)
+
+First release, sprouted from `freckle-app-1.18.2.0`.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2021-2024 Renaissance Learning Inc
+
+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/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,7 @@
+# freckle-env
+
+Some extensions to the `envparse` library.
+
+---
+
+[CHANGELOG](./CHANGELOG.md) | [LICENSE](./LICENSE)
diff --git a/doctest/Main.hs b/doctest/Main.hs
new file mode 100644
--- /dev/null
+++ b/doctest/Main.hs
@@ -0,0 +1,16 @@
+module Main (main) where
+
+import Prelude
+
+import Test.DocTest
+
+main :: IO ()
+main =
+  doctest
+    [ "library/"
+    , "-XGHC2021"
+    , "-XDerivingStrategies"
+    , "-XLambdaCase"
+    , "-XNoImplicitPrelude"
+    , "-XNoMonomorphismRestriction"
+    ]
diff --git a/freckle-env.cabal b/freckle-env.cabal
new file mode 100644
--- /dev/null
+++ b/freckle-env.cabal
@@ -0,0 +1,77 @@
+cabal-version:      1.18
+name:               freckle-env
+version:            0.0.0.0
+license:            MIT
+license-file:       LICENSE
+maintainer:         Freckle Education
+homepage:           https://github.com/freckle/freckle-app#readme
+bug-reports:        https://github.com/freckle/freckle-app/issues
+synopsis:           Some extension to the envparse library
+description:        Please see README.md
+category:           Environment, System
+build-type:         Simple
+extra-source-files: package.yaml
+extra-doc-files:
+    README.md
+    CHANGELOG.md
+
+source-repository head
+    type:     git
+    location: https://github.com/freckle/freckle-app
+
+library
+    exposed-modules:    Freckle.App.Env
+    hs-source-dirs:     library
+    other-modules:      Paths_freckle_env
+    default-language:   GHC2021
+    default-extensions:
+        DataKinds DeriveAnyClass DerivingVia DerivingStrategies GADTs
+        LambdaCase NoImplicitPrelude NoMonomorphismRestriction
+        OverloadedStrings TypeFamilies
+
+    ghc-options:
+        -fignore-optim-changes -fwrite-ide-info -Weverything
+        -Wno-all-missed-specialisations -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-kind-signatures
+        -Wno-missing-local-signatures -Wno-missing-safe-haskell-mode
+        -Wno-monomorphism-restriction -Wno-prepositive-qualified-module
+        -Wno-safe -Wno-unsafe
+
+    build-depends:
+        base >=4.16.4.0 && <5,
+        envparse >=0.5.0,
+        errors >=2.3.0,
+        relude >=1.1.0.0,
+        text >=1.2.5.0,
+        time >=1.11.1.1
+
+    if impl(ghc >=9.8)
+        ghc-options:
+            -Wno-missing-role-annotations -Wno-missing-poly-kind-signatures
+
+test-suite doctest
+    type:               exitcode-stdio-1.0
+    main-is:            Main.hs
+    hs-source-dirs:     doctest
+    other-modules:      Paths_freckle_env
+    default-language:   GHC2021
+    default-extensions:
+        DataKinds DeriveAnyClass DerivingVia DerivingStrategies GADTs
+        LambdaCase NoImplicitPrelude NoMonomorphismRestriction
+        OverloadedStrings TypeFamilies
+
+    ghc-options:
+        -fignore-optim-changes -fwrite-ide-info -Weverything
+        -Wno-all-missed-specialisations -Wno-missing-exported-signatures
+        -Wno-missing-import-lists -Wno-missing-kind-signatures
+        -Wno-missing-local-signatures -Wno-missing-safe-haskell-mode
+        -Wno-monomorphism-restriction -Wno-prepositive-qualified-module
+        -Wno-safe -Wno-unsafe
+
+    build-depends:
+        base >=4.16.4.0 && <5,
+        doctest >=0.20.1
+
+    if impl(ghc >=9.8)
+        ghc-options:
+            -Wno-missing-role-annotations -Wno-missing-poly-kind-signatures
diff --git a/library/Freckle/App/Env.hs b/library/Freckle/App/Env.hs
new file mode 100644
--- /dev/null
+++ b/library/Freckle/App/Env.hs
@@ -0,0 +1,172 @@
+-- | Parse the shell environment for configuration
+--
+-- A minor extension of [envparse](https://hackage.haskell.org/package/envparse).
+--
+-- Usage:
+--
+-- > import Freckle.App.Env
+-- >
+-- > data Config = Config -- Example
+-- >   { cBatchSize :: Natural
+-- >   , cDryRun :: Bool
+-- >   , cLogLevel :: LogLevel
+-- >   }
+-- >
+-- > loadConfig :: IO Config
+-- > loadConfig = parse $ Config
+-- >   <$> var auto "BATCH_SIZE" (def 1)
+-- >   <*> switch "DRY_RUN" mempty
+-- >   <*> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty
+module Freckle.App.Env
+  ( module Env
+
+    -- * Replacements
+  , Off (..)
+  , On (..)
+  , flag
+
+    -- * Extensions
+  , Timeout (..)
+  , eitherReader
+  , time
+  , keyValues
+  , keyValue
+  , splitOnParse
+  , timeout
+  ) where
+
+import Relude
+
+import Control.Error.Util (note)
+import Data.Char (isDigit)
+import Data.Text qualified as T
+import Data.Time (UTCTime, defaultTimeLocale, parseTimeM)
+import Env hiding (flag)
+import Env qualified
+import Prelude qualified as Unsafe (read)
+
+-- | Designates the value of a parameter when a flag is not provided.
+newtype Off a = Off a
+
+-- | Designates the value of a parameter when a flag is provided.
+newtype On a = On a
+
+-- | Parse a simple flag
+--
+-- If the variable is present and non-empty in the environment, the active value
+-- is returned, otherwise the default is used.
+--
+-- >>> data LogLevel = LevelDebug | LevelInfo | LevelWarn | LevelError | LevelOther Text deriving (Eq, Prelude.Show, Prelude.Read, Ord)
+--
+-- >>> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty `parsePure` [("DEBUG", "1")]
+-- Right LevelDebug
+--
+-- >>> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty `parsePure` [("DEBUG", "")]
+-- Right LevelInfo
+--
+-- >>> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty `parsePure` []
+-- Right LevelInfo
+--
+-- N.B. only the empty string is falsey:
+--
+-- >>> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty `parsePure` [("DEBUG", "false")]
+-- Right LevelDebug
+--
+-- >>> flag (Off LevelInfo) (On LevelDebug) "DEBUG" mempty `parsePure` [("DEBUG", "no")]
+-- Right LevelDebug
+flag :: Off a -> On a -> String -> Mod Flag a -> Parser Error a
+flag (Off f) (On t) n m = Env.flag f t n m
+
+-- | Create a 'Reader' from a simple parser function
+--
+-- This is a building-block for other 'Reader's
+eitherReader :: (String -> Either String a) -> Env.Reader Error a
+eitherReader f s = first (unread . suffix) $ f s
+ where
+  suffix x = x <> ": " <> show s
+
+-- | Read a time value using the given format
+--
+-- >>> var (time "%Y-%m-%d") "TIME" mempty `parsePure` [("TIME", "1985-02-12")]
+-- Right 1985-02-12 00:00:00 UTC
+--
+-- >>> var (time "%Y-%m-%d") "TIME" mempty `parsePure` [("TIME", "10:00PM")]
+-- Left [("TIME",UnreadError "unable to parse time as %Y-%m-%d: \"10:00PM\"")]
+time :: String -> Env.Reader Error UTCTime
+time fmt =
+  eitherReader
+    $ note ("unable to parse time as " <> fmt)
+    . parseTimeM True defaultTimeLocale fmt
+
+-- | Read key-value pairs
+--
+-- >>> var keyValues "TAGS" mempty `parsePure` [("TAGS", "foo:bar,baz:bat")]
+-- Right [("foo","bar"),("baz","bat")]
+--
+-- Value-less keys are not supported:
+--
+-- >>> var keyValues "TAGS" mempty `parsePure` [("TAGS", "foo,baz:bat")]
+-- Left [("TAGS",UnreadError "Key foo has no value: \"foo\"")]
+--
+-- Nor are key-less values:
+--
+-- >>> var keyValues "TAGS" mempty `parsePure` [("TAGS", "foo:bar,:bat")]
+-- Left [("TAGS",UnreadError "Value bat has no key: \":bat\"")]
+keyValues :: Env.Reader Error [(Text, Text)]
+keyValues = splitOnParse ',' $ keyValue ':'
+
+keyValue :: Char -> Env.Reader Error (Text, Text)
+keyValue c =
+  eitherReader $ go . second (T.drop 1) . T.breakOn (T.singleton c) . toText
+ where
+  go = \case
+    (k, v) | T.null v -> Left $ "Key " <> toString k <> " has no value"
+    (k, v) | T.null k -> Left $ "Value " <> toString v <> " has no key"
+    (k, v) -> Right (k, v)
+
+-- | Use 'splitOn' then call the given 'Reader' on each element
+--
+-- @
+-- 'splitOnParse' c pure == 'splitOn' c
+-- @
+--
+-- >>> var (splitOnParse @Error ',' nonempty) "X" mempty `parsePure` [("X", "a,b")] :: Either [(String, Error)] [Text]
+-- Right ["a","b"]
+--
+-- >>> var (splitOnParse @Error ',' nonempty) "X" mempty `parsePure` [("X", ",,")] :: Either [(String, Error)] [Text]
+-- Left [("X",EmptyError)]
+splitOnParse :: Char -> Env.Reader e a -> Env.Reader e [a]
+splitOnParse c p = traverse p <=< splitOn c
+
+-- | Represents a timeout in seconds or milliseconds
+data Timeout
+  = TimeoutSeconds Int
+  | TimeoutMilliseconds Int
+  deriving stock (Show, Eq)
+
+-- | Read a timeout value as seconds or milliseconds
+--
+-- >>> var timeout "TIMEOUT" mempty `parsePure` [("TIMEOUT", "10")]
+-- Right (TimeoutSeconds 10)
+--
+-- >>> var timeout "TIMEOUT" mempty `parsePure` [("TIMEOUT", "10s")]
+-- Right (TimeoutSeconds 10)
+--
+-- >>> var timeout "TIMEOUT" mempty `parsePure` [("TIMEOUT", "10ms")]
+-- Right (TimeoutMilliseconds 10)
+--
+-- >>> var timeout "TIMEOUT" mempty `parsePure` [("TIMEOUT", "20m")]
+-- Left [("TIMEOUT",UnreadError "must be {digits}(s|ms): \"20m\"")]
+--
+-- >>> var timeout "TIMEOUT" mempty `parsePure` [("TIMEOUT", "2m0")]
+-- Left [("TIMEOUT",UnreadError "must be {digits}(s|ms): \"2m0\"")]
+timeout :: Env.Reader Error Timeout
+timeout = eitherReader $ parseTimeout . span isDigit
+ where
+  parseTimeout = \case
+    ("", _) -> Left "must be {digits}(s|ms)"
+    (digits, "") -> Right $ TimeoutSeconds $ Unsafe.read digits
+    (digits, "s") -> Right $ TimeoutSeconds $ Unsafe.read digits
+    (digits, "ms") ->
+      Right $ TimeoutMilliseconds $ Unsafe.read digits
+    _ -> Left "must be {digits}(s|ms)"
diff --git a/package.yaml b/package.yaml
new file mode 100644
--- /dev/null
+++ b/package.yaml
@@ -0,0 +1,68 @@
+name: freckle-env
+version: 0.0.0.0
+maintainer: Freckle Education
+category: Environment, System
+github: freckle/freckle-app
+synopsis: Some extension to the envparse library
+description: Please see README.md
+
+extra-doc-files:
+  - README.md
+  - CHANGELOG.md
+
+extra-source-files:
+  - package.yaml
+
+language: GHC2021
+
+ghc-options:
+  - -fignore-optim-changes
+  - -fwrite-ide-info
+  - -Weverything
+  - -Wno-all-missed-specialisations
+  - -Wno-missing-exported-signatures # re-enables missing-signatures
+  - -Wno-missing-import-lists
+  - -Wno-missing-kind-signatures
+  - -Wno-missing-local-signatures
+  - -Wno-missing-safe-haskell-mode
+  - -Wno-monomorphism-restriction
+  - -Wno-prepositive-qualified-module
+  - -Wno-safe
+  - -Wno-unsafe
+
+when:
+  - condition: "impl(ghc >= 9.8)"
+    ghc-options:
+      - -Wno-missing-role-annotations
+      - -Wno-missing-poly-kind-signatures
+
+dependencies:
+  - base < 5
+
+default-extensions:
+  - DataKinds
+  - DeriveAnyClass
+  - DerivingVia
+  - DerivingStrategies
+  - GADTs
+  - LambdaCase
+  - NoImplicitPrelude
+  - NoMonomorphismRestriction
+  - OverloadedStrings
+  - TypeFamilies
+
+library:
+  source-dirs: library
+  dependencies:
+    - envparse
+    - errors
+    - relude
+    - text
+    - time
+
+tests:
+  doctest:
+    main: Main.hs
+    source-dirs: doctest
+    dependencies:
+      - doctest
