packages feed

okf-cli-0.1.2.0: src/Okf/Cli/Config.hs

-- | Project and global configuration for the okf CLI, loaded from Dhall.
module Okf.Cli.Config
  ( OkfConfig (..),
    KitSettings (..),
    AssistSettings (..),
    OkfProvider (..),
    ConfigSource (..),
    defaultOkfConfig,
    loadOkfConfig,
    findConfigSource,
    renderConfigSource,
    exampleConfigText,
    renderConfig,
    okfConfigEnvVar,
    projectConfigPath,
    xdgConfigPath,
    dotConfigPath,
  )
where

import Control.Exception (SomeException, catch)
import Data.Text qualified as Text
import Dhall (FromDhall (..), auto, genericAutoWith)
import Dhall qualified
import Okf.Prelude
import System.Directory (doesFileExist, getCurrentDirectory, getHomeDirectory)
import System.Environment (lookupEnv)
import System.FilePath ((</>))

-- | Which interactive agent provider a setting refers to. This okf-local enum
-- keeps config loading free of a direct dependency on Baikai provider types.
data OkfProvider
  = ProviderClaude
  | ProviderCodex
  deriving stock (Generic, Eq, Show)

instance FromDhall OkfProvider where
  autoWith _normalizer =
    genericAutoWith
      (Dhall.defaultInterpretOptions {Dhall.constructorModifier = stripProviderPrefix})
    where
      stripProviderPrefix name = fromMaybe name (Text.stripPrefix "Provider" name)

-- | Kit-related settings: where to fetch skills/subagents and which providers
-- to install for.
data KitSettings = KitSettings
  { repoUrl :: !Text,
    providers :: ![OkfProvider]
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromDhall)

-- | Assist-related settings: which provider to launch and optional overrides.
data AssistSettings = AssistSettings
  { provider :: !OkfProvider,
    model :: !(Maybe Text),
    systemPrompt :: !(Maybe Text)
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromDhall)

-- | The whole okf configuration.
data OkfConfig = OkfConfig
  { kit :: !KitSettings,
    assist :: !AssistSettings
  }
  deriving stock (Generic, Eq, Show)
  deriving anyclass (FromDhall)

-- | Where the effective configuration came from.
data ConfigSource
  = SourceEnv !FilePath
  | SourceProject !FilePath
  | SourceXdg !FilePath
  | SourceDot !FilePath
  | SourceDefaults
  deriving stock (Eq, Show)

defaultOkfConfig :: OkfConfig
defaultOkfConfig =
  OkfConfig
    { kit =
        KitSettings
          { repoUrl = "https://github.com/shinzui/okf-kit.git",
            providers = [ProviderClaude]
          },
      assist =
        AssistSettings
          { provider = ProviderClaude,
            model = Nothing,
            systemPrompt = Nothing
          }
    }

okfConfigEnvVar :: String
okfConfigEnvVar = "OKF_CONFIG"

projectConfigPath :: IO FilePath
projectConfigPath = (</> "okf-config.dhall") <$> getCurrentDirectory

xdgConfigPath :: IO FilePath
xdgConfigPath = (\home -> home </> ".config" </> "okf" </> "config.dhall") <$> getHomeDirectory

dotConfigPath :: IO FilePath
dotConfigPath = (\home -> home </> ".okf" </> "config.dhall") <$> getHomeDirectory

-- | Resolve which config file to use. The first existing file wins; when none
-- exists, okf uses built-in defaults.
findConfigSource :: IO ConfigSource
findConfigSource = do
  mEnv <- lookupEnv okfConfigEnvVar
  case mEnv of
    Just path -> do
      exists <- doesFileExist path
      if exists then pure (SourceEnv path) else searchFiles
    Nothing -> searchFiles
  where
    searchFiles = do
      projectPath <- projectConfigPath
      xdgPath <- xdgConfigPath
      dotPath <- dotConfigPath
      firstExisting
        [ (SourceProject, projectPath),
          (SourceXdg, xdgPath),
          (SourceDot, dotPath)
        ]

    firstExisting [] = pure SourceDefaults
    firstExisting ((mkSource, path) : rest) = do
      exists <- doesFileExist path
      if exists then pure (mkSource path) else firstExisting rest

-- | Load the effective configuration and report its source. A parse or type
-- error in a found file is returned as 'Left'; a missing file yields defaults.
loadOkfConfig :: IO (Either Text (OkfConfig, ConfigSource))
loadOkfConfig = do
  configSource <- findConfigSource
  case sourcePath configSource of
    Nothing -> pure (Right (defaultOkfConfig, configSource))
    Just path ->
      ( do
          config <- Dhall.inputFile auto path
          pure (Right (config, configSource))
      )
        `catch` \(exception :: SomeException) ->
          pure (Left (Text.pack (show exception)))

sourcePath :: ConfigSource -> Maybe FilePath
sourcePath = \case
  SourceEnv path -> Just path
  SourceProject path -> Just path
  SourceXdg path -> Just path
  SourceDot path -> Just path
  SourceDefaults -> Nothing

renderConfigSource :: ConfigSource -> Text
renderConfigSource = \case
  SourceEnv path -> "OKF_CONFIG=" <> Text.pack path
  SourceProject path -> Text.pack path
  SourceXdg path -> Text.pack path
  SourceDot path -> Text.pack path
  SourceDefaults -> "(built-in defaults)"

-- | Human-readable dump of the effective configuration.
renderConfig :: OkfConfig -> Text
renderConfig
  OkfConfig
    { kit = KitSettings {repoUrl, providers},
      assist = AssistSettings {provider, model, systemPrompt}
    } =
    Text.unlines
      [ "kit.repoUrl     = " <> repoUrl,
        "kit.providers   = " <> renderProviders providers,
        "assist.provider = " <> renderProvider provider,
        "assist.model    = " <> fromMaybe "(unset)" model,
        "assist.systemPrompt = " <> fromMaybe "(unset)" systemPrompt
      ]

renderProviders :: [OkfProvider] -> Text
renderProviders providers = "[" <> Text.intercalate ", " (map renderProvider providers) <> "]"

renderProvider :: OkfProvider -> Text
renderProvider = \case
  ProviderClaude -> "claude"
  ProviderCodex -> "codex"

-- | The commented example written by @okf config init@.
exampleConfigText :: Text
exampleConfigText =
  Text.unlines
    [ "-- okf configuration. See `okf config show` for the effective values.",
      "let Provider = < Claude | Codex >",
      "in  { kit =",
      "        { repoUrl = \"https://github.com/shinzui/okf-kit.git\"",
      "        , providers = [ Provider.Claude ]",
      "        }",
      "    , assist =",
      "        { provider = Provider.Claude",
      "        , model = None Text",
      "        , systemPrompt = None Text",
      "        }",
      "    }"
    ]