packages feed

effectful-cli 0.2.5.0 → 0.2.6.0

raw patch · 8 files changed

+447/−7 lines, 8 filesdep +containersdep +directorydep +filepathPVP: major bump suggested

API removals or changes: PVP suggests a major version bump

Dependencies added: containers, directory, filepath, toml-parser

API changes (from Hackage documentation)

+ Data.CLI.Config: -- anything else.
+ Data.CLI.Config: -- | Type the config file decodes to; <a>ConfigSet</a> refuses to write
+ Data.CLI.Config: ConfigGet :: Text -> ConfigCommand (c :: k) (e :: k1)
+ Data.CLI.Config: ConfigSet :: Text -> Text -> ConfigCommand (c :: k) (e :: k1)
+ Data.CLI.Config: ConfigShow :: ConfigCommand (c :: k) (e :: k1)
+ Data.CLI.Config: class FromValue ConfigOf c => ConfigFile c where {
+ Data.CLI.Config: configFilePath :: ConfigFile c => c -> FilePath
+ Data.CLI.Config: data ConfigCommand (c :: k) (e :: k1)
+ Data.CLI.Config: instance Data.CLI.Config.ConfigFile c => Data.CLI.Class.Command c e (Data.CLI.Config.ConfigCommand c e)
+ Data.CLI.Config: instance forall k1 (c :: k1) k2 (e :: k2). GHC.Classes.Eq (Data.CLI.Config.ConfigCommand c e)
+ Data.CLI.Config: instance forall k1 (c :: k1) k2 (e :: k2). GHC.Internal.Show.Show (Data.CLI.Config.ConfigCommand c e)
+ Data.CLI.Config: instance forall k1 k2 (c :: k1) (e :: k2). GHC.Internal.Enum.Bounded (Data.CLI.Config.ConfigCommand c e)
+ Data.CLI.Config: instance forall k1 k2 (c :: k1) (e :: k2). GHC.Internal.Enum.Enum (Data.CLI.Config.ConfigCommand c e)
+ Data.CLI.Config: type ConfigOf c;
+ Data.CLI.Config: }
+ Effectful.CLI.Config: getConfigKey :: forall (es :: [Effect]). (IOE :> es, Error CLIError :> es) => FilePath -> Text -> Eff es Text
+ Effectful.CLI.Config: lookupKey :: [Text] -> Table -> Maybe Value
+ Effectful.CLI.Config: parseValue :: Text -> Value
+ Effectful.CLI.Config: readConfigText :: forall (es :: [Effect]). IOE :> es => FilePath -> Eff es Text
+ Effectful.CLI.Config: setConfigKey :: forall a (es :: [Effect]). (FromValue a, IOE :> es, Error CLIError :> es) => FilePath -> Text -> Text -> Eff es ()
+ Effectful.CLI.Config: setKey :: [Text] -> Value -> Table -> Either Text Table
- Effectful.CLI.Conc: concurrently :: forall (es :: [Effect]) a b. (HasCallStack, Concurrent :> es) => Eff es a -> Eff es b -> Eff es (a, b)
+ Effectful.CLI.Conc: concurrently :: forall (es :: [Effect]) a b. Concurrent :> es => Eff es a -> Eff es b -> Eff es (a, b)
- Effectful.CLI.Conc: concurrently_ :: forall (es :: [Effect]) a b. (HasCallStack, Concurrent :> es) => Eff es a -> Eff es b -> Eff es ()
+ Effectful.CLI.Conc: concurrently_ :: forall (es :: [Effect]) a b. Concurrent :> es => Eff es a -> Eff es b -> Eff es ()
- Effectful.CLI.Conc: forConcurrently :: forall f (es :: [Effect]) a b. (HasCallStack, Traversable f, Concurrent :> es) => f a -> (a -> Eff es b) -> Eff es (f b)
+ Effectful.CLI.Conc: forConcurrently :: forall (es :: [Effect]) t a b. (Concurrent :> es, Traversable t) => t a -> (a -> Eff es b) -> Eff es (t b)
- Effectful.CLI.Conc: forConcurrently_ :: forall f (es :: [Effect]) a b. (HasCallStack, Foldable f, Concurrent :> es) => f a -> (a -> Eff es b) -> Eff es ()
+ Effectful.CLI.Conc: forConcurrently_ :: forall (es :: [Effect]) f a b. (Concurrent :> es, Foldable f) => f a -> (a -> Eff es b) -> Eff es ()
- Effectful.CLI.Conc: mapConcurrently :: forall f (es :: [Effect]) a b. (HasCallStack, Traversable f, Concurrent :> es) => (a -> Eff es b) -> f a -> Eff es (f b)
+ Effectful.CLI.Conc: mapConcurrently :: forall (es :: [Effect]) t a b. (Concurrent :> es, Traversable t) => (a -> Eff es b) -> t a -> Eff es (t b)
- Effectful.CLI.Conc: mapConcurrently_ :: forall f (es :: [Effect]) a b. (HasCallStack, Foldable f, Concurrent :> es) => (a -> Eff es b) -> f a -> Eff es ()
+ Effectful.CLI.Conc: mapConcurrently_ :: forall (es :: [Effect]) f a b. (Concurrent :> es, Foldable f) => (a -> Eff es b) -> f a -> Eff es ()

Files

CHANGELOG.md view
@@ -1,3 +1,9 @@+## [2.6.0](https://github.com/haskell-github-trust/effectful-cli/compare/2.5.0...2.6.0) (2026-11-06)++### Features++* add config commands ([a21cd10](https://github.com/haskell-github-trust/effectful-cli/commit/a21cd10e8331b786ae877e78f6593314ae9242ee))+ ## [2.5.0](https://github.com/haskell-github-trust/effectful-cli/compare/2.4.2...2.5.0) (2026-11-05)  ### Features
README.md view
@@ -4,6 +4,73 @@ effect constraints, concurrency, prompts, sleep/time, optparse-applicative helpers and a [monad-logger](https://hackage.haskell.org/package/monad-logger) bridge (stderr or OpenTelemetry). +## Quick start++Add the dependencies to `package.yaml`:++```yaml+dependencies:+  - effectful+  - effectful-cli+  - http-client-effectful+  - monad-logger+  - optparse-applicative+  - text+```++Minimal `Main.hs`, one `hello` subcommand logging to stderr:++```haskell+{-# LANGUAGE OverloadedStrings #-}++import Control.Monad.Logger (logInfoN)+import Data.CLI.Class+import Data.CLI.Impl (execCLIParser, mkCommand, usage)+import Data.Error.CLI (CLIError, displayCLIError)+import Data.Text qualified as T+import Data.Text.IO qualified as T+import Effectful+import Effectful.Error.Static (runErrorNoCallStack)+import Effectful.HttpClient (runHttpClientTls)+import Effectful.Logger (runStderrLogger)+import Effectful.Reader.Static (runReader)+import Options.Applicative (hsubparser, strArgument)++data Config = Config++instance CLIConfig Config where+  autoAccept _ = False+  autoDecline _ = False++newtype Hello = Hello String++instance Command Config CLIError Hello where+  cmdname _ = "hello"+  doc _ = "greet someone"+  parser _ = Hello <$> strArgument mempty+  runner (Hello name) = logInfoN ("hello " <> T.pack name)++main :: IO ()+main = do+  cmd <- execCLIParser+         (usage "hello" "0.1.0" "greeter" (hsubparser (mkCommand (Hello ""))))+  res <-+    runEff .+    runHttpClientTls .+    runStderrLogger .+    runReader Config .+    runErrorNoCallStack @CLIError .+    runErrorNoCallStack @CLIError $+    runner cmd+  either (T.putStrLn . displayCLIError) pure (res >>= id)+```++```sh+stack run -- hello world+```++See the `Effectful.CLI` module docs for the walkthrough.+ ## Build  ```sh
effectful-cli.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:           effectful-cli-version:        0.2.5.0+version:        0.2.6.0 synopsis:       CLI helpers for the effectful ecosystem description:    Please see the README at https://github.com/haskell-github-trust/effectful-cli category:       CLI@@ -24,10 +24,12 @@   exposed-modules:       Data.CLI.Args       Data.CLI.Class+      Data.CLI.Config       Data.CLI.Impl       Data.Error.CLI       Effectful.CLI       Effectful.CLI.Conc+      Effectful.CLI.Config       Effectful.CLI.Exception       Effectful.CLI.IO       Effectful.CLI.Sleep@@ -40,8 +42,11 @@   ghc-options: -Wall -Wextra -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints   build-depends:       base >=4.20 && <4.22+    , containers >=0.6.8 && <0.8+    , directory >=1.3.8 && <1.4     , effectful >=2.6.1 && <2.8     , effectful-core >=2.6.1 && <2.8+    , filepath >=1.4.300 && <1.6     , hs-opentelemetry-api ==1.0.*     , hs-opentelemetry-instrumentation-monad-logger ==1.0.*     , http-client-effectful ==1.0.*@@ -51,6 +56,7 @@     , table-layout ==1.0.*     , text ==2.1.*     , time >=1.12.2 && <1.15+    , toml-parser >=2.0.1 && <2.1     , uuid >=1.3.16 && <1.4   default-language: GHC2021 @@ -64,9 +70,12 @@   ghc-options: -Wall -Wextra -Wcompat -Widentities -Wincomplete-record-updates -Wincomplete-uni-patterns -Wmissing-export-lists -Wmissing-home-modules -Wpartial-fields -Wredundant-constraints -threaded -rtsopts -with-rtsopts=-N   build-depends:       base >=4.20 && <4.22+    , directory     , effectful     , effectful-cli+    , filepath     , hspec ==2.11.*     , megaparsec     , optparse-applicative+    , toml-parser   default-language: GHC2021
+ src/Data/CLI/Config.hs view
@@ -0,0 +1,85 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TypeFamilies #-}++-- | Ready-made @show@/@get@/@set@ commands over the application's TOML config file.+module Data.CLI.Config (+  ConfigFile (..),+  ConfigCommand (..),+) where++import Data.CLI.Class (Command (..))+import Data.CLI.Impl (noArgs, unboundEnumError)+import Data.Error.CLI (CLIError (InputError))+import Data.Kind (Type)+import Data.Text (Text)+import Data.Text qualified as T+import Effectful.CLI.Config (getConfigKey, readConfigText, setConfigKey)+import Effectful.CLI.Exception (rethrowIO)+import Effectful.CLI.IO (printOutputLn)+import Effectful.Reader.Static (asks)+import GHC.Enum (boundedEnumFrom, boundedEnumFromThen)+import Options.Applicative (Parser, metavar, strArgument)+import Toml.Schema (FromValue)++-- | Application config @c@ that knows its config file and the type it decodes to.+--+-- @since 0.2.6.0+class FromValue (ConfigOf c) => ConfigFile c where+  -- | Type the config file decodes to; 'ConfigSet' refuses to write anything else.+  type ConfigOf c :: Type++  -- | Path of the config file.+  configFilePath :: c -> FilePath++-- | Config file commands for config @c@ and application error @e@. IO failures+-- are raised as 'InputError'.+--+-- @since 0.2.6.0+data ConfigCommand c e+  = -- | Print the config file.+    ConfigShow+  | -- | Print a dotted key's value.+    ConfigGet Text+  | -- | Set a dotted key to a TOML literal, or a string if not one.+    ConfigSet Text Text+  deriving (Eq, Show)++instance Bounded (ConfigCommand c e) where+  minBound = ConfigShow+  maxBound = ConfigSet mempty mempty++instance Enum (ConfigCommand c e) where+  fromEnum ConfigShow = 0+  fromEnum (ConfigGet _) = 1+  fromEnum (ConfigSet _ _) = 2++  toEnum 0 = minBound+  toEnum 1 = ConfigGet mempty+  toEnum 2 = maxBound+  toEnum _ = unboundEnumError "ConfigCommand"++  enumFrom = boundedEnumFrom+  enumFromThen = boundedEnumFromThen++instance ConfigFile c => Command c e (ConfigCommand c e) where+  cmdname ConfigShow = "show"+  cmdname (ConfigGet _) = "get"+  cmdname (ConfigSet _ _) = "set"++  doc ConfigShow = "Print the config file"+  doc (ConfigGet _) = "Print a dotted key's value (e.g. section.key)"+  doc (ConfigSet _ _) = "Set a dotted key to a TOML literal, or a string if not one"++  parser ConfigShow = noArgs ConfigShow+  parser (ConfigGet _) = ConfigGet <$> keyArg+  parser (ConfigSet _ _) = ConfigSet <$> keyArg <*> strArgument (metavar "VALUE")++  runner cmd = do+    path <- asks @c configFilePath+    rethrowIO (InputError . ("config file: " <>) . T.show) $ case cmd of+      ConfigShow -> printOutputLn . T.stripEnd =<< readConfigText path+      ConfigGet k -> printOutputLn =<< getConfigKey path k+      ConfigSet k v -> setConfigKey @(ConfigOf c) path k v++keyArg :: Parser Text+keyArg = strArgument (metavar "KEY")
src/Effectful/CLI.hs view
@@ -1,4 +1,52 @@ -- | Effect stack available to command runners.+--+-- = Tutorial+--+-- Define a config implementing 'Data.CLI.Class.CLIConfig' and a command implementing+-- 'Data.CLI.Class.Command', then run its 'Data.CLI.Class.runner' with every effect of+-- 'CLIEffects' discharged:+--+-- > {-# LANGUAGE OverloadedStrings #-}+-- >+-- > import Data.CLI.Class+-- > import Data.CLI.Impl (execCLIParser, mkCommand, usage)+-- > import Data.Error.CLI (CLIError, displayCLIError)+-- > import Control.Monad.Logger (logInfoN)+-- > import Data.Text qualified as T+-- > import Data.Text.IO qualified as T+-- > import Effectful+-- > import Effectful.Error.Static (runErrorNoCallStack)+-- > import Effectful.HttpClient (runHttpClientTls)+-- > import Effectful.Logger (runStderrLogger)+-- > import Effectful.Reader.Static (runReader)+-- > import Options.Applicative (hsubparser, strArgument)+-- >+-- > data Config = Config+-- >+-- > instance CLIConfig Config where+-- >   autoAccept _ = False+-- >   autoDecline _ = False+-- >+-- > newtype Hello = Hello String+-- >+-- > instance Command Config CLIError Hello where+-- >   cmdname _ = "hello"+-- >   doc _ = "greet someone"+-- >   parser _ = Hello <$> strArgument mempty+-- >   runner (Hello name) = logInfoN ("hello " <> T.pack name)+-- >+-- > main :: IO ()+-- > main = do+-- >   cmd <- execCLIParser (usage "hello" "0.1.0" "greeter" (hsubparser (mkCommand (Hello ""))))+-- >   res <-+-- >     runEff . runHttpClientTls . runStderrLogger . runReader Config+-- >       . runErrorNoCallStack @CLIError . runErrorNoCallStack @CLIError+-- >       $ runner cmd+-- >   either (T.putStrLn . displayCLIError) pure (res >>= id)+--+-- Here the application error @e@ is 'CLIError' itself; use your own type and handle it+-- in the second 'Effectful.Error.Static.runErrorNoCallStack'. See "Data.CLI.Impl" for+-- more parser helpers and "Effectful.CLI.IO" for confirmation prompts. module Effectful.CLI (   CLIEffects, ) where
src/Effectful/CLI/Conc.hs view
@@ -19,15 +19,74 @@ import Effectful (Eff, (:>)) import Effectful.Concurrent.Async (   Concurrent,-  concurrently,-  concurrently_,-  forConcurrently,-  forConcurrently_,-  mapConcurrently,-  mapConcurrently_,   pooledMapConcurrentlyN,   pooledMapConcurrentlyN_,  )+import Effectful.Concurrent.Async qualified as Async++-- | Run two actions concurrently and return both results.+concurrently+  :: Concurrent :> es+  => Eff es a+  -- ^ first action+  -> Eff es b+  -- ^ second action+  -> Eff es (a, b)+concurrently = Async.concurrently++-- | Like 'concurrently', discarding results.+concurrently_+  :: Concurrent :> es+  => Eff es a+  -- ^ first action+  -> Eff es b+  -- ^ second action+  -> Eff es ()+concurrently_ = Async.concurrently_++-- | Map an action over a container concurrently, without a concurrency limit.+mapConcurrently+  :: Concurrent :> es+  => Traversable t+  => (a -> Eff es b)+  -- ^ action for each element+  -> t a+  -- ^ elements+  -> Eff es (t b)+mapConcurrently = Async.mapConcurrently++-- | Like 'mapConcurrently', discarding results.+mapConcurrently_+  :: Concurrent :> es+  => Foldable f+  => (a -> Eff es b)+  -- ^ action for each element+  -> f a+  -- ^ elements+  -> Eff es ()+mapConcurrently_ = Async.mapConcurrently_++-- | 'mapConcurrently' with its arguments flipped.+forConcurrently+  :: Concurrent :> es+  => Traversable t+  => t a+  -- ^ elements+  -> (a -> Eff es b)+  -- ^ action for each element+  -> Eff es (t b)+forConcurrently = Async.forConcurrently++-- | Like 'forConcurrently', discarding results.+forConcurrently_+  :: Concurrent :> es+  => Foldable f+  => f a+  -- ^ elements+  -> (a -> Eff es b)+  -- ^ action for each element+  -> Eff es ()+forConcurrently_ = Async.forConcurrently_  -- | Run all actions concurrently and collect their results. --
+ src/Effectful/CLI/Config.hs view
@@ -0,0 +1,139 @@+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE OverloadedStrings #-}++-- | Read and edit a TOML config file by dotted key (@github.project.team@).+module Effectful.CLI.Config (+  readConfigText,+  getConfigKey,+  setConfigKey,+  lookupKey,+  setKey,+  parseValue,+) where++import Control.Monad ((<=<))+import Data.Error.CLI (CLIError (..))+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.IO qualified as T+import Effectful (Eff, IOE, liftIO, (:>))+import Effectful.Error.Static (Error, throwError)+import System.Directory (createDirectoryIfMissing, doesFileExist)+import System.FilePath (takeDirectory)+import Toml (+  Result (..),+  Table,+  Table' (..),+  Value,+  Value' (..),+  forgetTableAnns,+  prettyToml,+ )+import Toml qualified+import Toml.Pretty (prettyValue)+import Toml.Schema (FromValue)++-- | The config file's contents, empty if it does not exist.+--+-- @since 0.2.6.0+readConfigText+  :: IOE :> es+  => FilePath+  -> Eff es Text+readConfigText path = liftIO $ do+  exists <- doesFileExist path+  if exists then T.readFile path else pure ""++-- | Render a dotted key's value: strings unquoted, tables as TOML sections.+--+-- @since 0.2.6.0+getConfigKey+  :: IOE :> es+  => Error CLIError :> es+  => FilePath+  -> Text+  -> Eff es Text+getConfigKey path key =+  maybe (throwError (NotFoundError key)) (pure . render) . lookupKey (T.splitOn "." key)+    =<< readTable path+ where+  render (Text t) = t+  render (Table t) = T.stripEnd (T.show (prettyToml t))+  render v = T.show (prettyValue v)++-- | Set a dotted key to 'parseValue' of the raw input, creating intermediate+-- tables; the file is only written if the result still decodes as @a@.+-- Re-renders the whole file, dropping comments and formatting.+--+-- @since 0.2.6.0+setConfigKey+  :: forall a es+   . FromValue a+  => IOE :> es+  => Error CLIError :> es+  => FilePath+  -> Text+  -> Text+  -> Eff es ()+setConfigKey path key raw = do+  table <-+    either (throwError . InputError) pure . setKey (T.splitOn "." key) (parseValue raw)+      =<< readTable path+  let rendered = T.show (prettyToml table)+  case Toml.decode @a rendered of+    Failure es -> throwError (InputError ("invalid config: " <> T.pack (unlines es)))+    Success _ _ -> liftIO $ do+      createDirectoryIfMissing True (takeDirectory path)+      T.writeFile path rendered++readTable+  :: IOE :> es+  => Error CLIError :> es+  => FilePath+  -> Eff es Table+readTable =+  either (throwError . InputError . T.pack) (pure . forgetTableAnns) . Toml.parse+    <=< readConfigText++-- | Value at a dotted key path, the empty path being the whole table.+--+-- @since 0.2.6.0+lookupKey+  :: [Text]+  -> Table+  -> Maybe Value+lookupKey [] t = Just (Table t)+lookupKey (k : ks) (MkTable m) = case (ks, snd <$> Map.lookup k m) of+  ([], v) -> v+  (_, Just (Table t)) -> lookupKey ks t+  _ -> Nothing++-- | Set a value at a dotted key path, creating missing tables; fails when a+-- path segment is not a table.+--+-- @since 0.2.6.0+setKey+  :: [Text]+  -> Value+  -> Table+  -> Either Text Table+setKey [] _ _ = Left "empty key"+setKey [k] v (MkTable m) = Right (MkTable (Map.insert k ((), v) m))+setKey (k : ks) v (MkTable m) = case snd <$> Map.lookup k m of+  Nothing -> descend (MkTable mempty)+  Just (Table t) -> descend t+  Just _ -> Left (k <> " is not a table")+ where+  descend t = (\t' -> MkTable (Map.insert k ((), Table t') m)) <$> setKey ks v t++-- | A TOML literal (@true@, @42@, @["a"]@, @"x"@), else the raw text as a string.+--+-- @since 0.2.6.0+parseValue :: Text -> Value+parseValue raw =+  either+    (const (Text raw))+    (fromMaybe (Text raw) . lookupKey ["v"] . forgetTableAnns)+    (Toml.parse ("v = " <> raw))
test/Spec.hs view
@@ -13,13 +13,17 @@ import Data.List (isInfixOf) import Effectful (Eff, IOE, liftIO, runEff) import Effectful.CLI.Conc (batchConcurrentlyWithIndex)+import Effectful.CLI.Config (getConfigKey, lookupKey, parseValue, setConfigKey, setKey) import Effectful.CLI.Exception (rethrowIO) import Effectful.CLI.IO (resolveIndex) import Effectful.Concurrent (runConcurrent) import Effectful.Error.Static (Error, runErrorNoCallStack) import Options.Applicative+import System.Directory (getTemporaryDirectory, removeFile)+import System.FilePath ((</>)) import Test.Hspec import Text.Megaparsec.Char (string)+import Toml (Table, Table' (..), Value' (..))  isInputError :: Either CLIError a -> Bool isInputError (Left (InputError _)) = True@@ -113,3 +117,26 @@               <|> subparser (hidden <> command "b" (info (subparser (leaf "c")) (progDesc "b desc")))        in show (commandTree p)             `shouldBe` "a            a desc\nb            b desc\n  c            c desc"++  describe "config keys" $ do+    it "parses TOML literals, falling back to strings" $+      fmap parseValue ["true", "\"x\"", "plain"]+        `shouldBe` [Bool True, Text "x", Text "plain"]++    it "sets nested keys and reads them back" $+      ( lookupKey ["a", "b", "c"]+          =<< either (const Nothing) Just (setKey ["a", "b", "c"] (Text "t") (MkTable mempty))+      )+        `shouldBe` Just (Text "t")++    it "refuses to descend into a scalar" $+      (setKey ["a", "b"] (Bool True) =<< setKey ["a"] (Bool False) (MkTable mempty))+        `shouldBe` Left "a is not a table"++    it "writes then reads a key through the file" $ do+      path <- (</> "effectful-cli-config-test.toml") <$> getTemporaryDirectory+      r <- runEff . runErrorNoCallStack @CLIError $ do+        setConfigKey @Table path "a.b" "1"+        getConfigKey path "a.b"+      removeFile path+      r `shouldBe` Right "1"