okf-cli-0.6.0.0: src/Okf/Cli/Config.hs
-- | Project and global configuration for the okf CLI, loaded from Dhall.
--
-- Two resolution models live here, deliberately. Everything except the @agent@
-- block resolves /first file found wins/: 'findConfigSource' picks one file and
-- the others are never read. The @agent@ block instead resolves across two
-- scopes — 'findConfigScopes' and 'loadAgentScopes' return the project-local and
-- global files independently, so a project can override one agent field while
-- inheriting the rest from the user's global file. Layering the other blocks
-- would mean making every one of their fields optional, which breaks every
-- configuration file already written, in exchange for a merge of single values
-- that have no per-command dimension.
module Okf.Cli.Config
( OkfConfig (..),
KitSettings (..),
ProfileSettings (..),
AgentFieldSettings (..),
AgentSettings (..),
OkfProvider (..),
OkfEffort (..),
ConfigSource (..),
ConfigScope (..),
ConfigScopes (..),
defaultOkfConfig,
defaultAgentSettings,
emptyAgentFieldSettings,
agentSharedDefaults,
loadOkfConfig,
loadAgentScopes,
findConfigSource,
findConfigScopes,
renderConfigSource,
renderOkfEffort,
renderOkfProvider,
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 Okf.Profile.Registry (defaultRegistryReference)
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)
-- | How hard a reasoning-capable model should deliberate before answering.
--
-- Like 'OkfProvider' this is an okf-local enum rather than Baikai's
-- @ThinkingLevel@, so a misspelled level fails during Dhall decoding with a
-- typed error naming the allowed constructors. The conversion to Baikai's
-- vocabulary lives in "Okf.Cli.Agent.Config", at the point of use.
data OkfEffort
= EffortMinimal
| EffortLow
| EffortMedium
| EffortHigh
| EffortXHigh
| EffortMax
deriving stock (Generic, Eq, Show, Enum, Bounded)
instance FromDhall OkfEffort where
autoWith _normalizer =
genericAutoWith
(Dhall.defaultInterpretOptions {Dhall.constructorModifier = stripEffortPrefix})
where
stripEffortPrefix name = fromMaybe name (Text.stripPrefix "Effort" name)
renderOkfEffort :: OkfEffort -> Text
renderOkfEffort = \case
EffortMinimal -> "minimal"
EffortLow -> "low"
EffortMedium -> "medium"
EffortHigh -> "high"
EffortXHigh -> "xhigh"
EffortMax -> "max"
-- | 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)
-- | The @assist@ block okf carried before the @agent@ block replaced it. It is
-- no longer part of 'OkfConfig'; it survives only as a decode shape, so that a
-- configuration file written for an earlier release still loads and its values
-- are carried onto the keys that replaced them.
data LegacyAssistSettings = LegacyAssistSettings
{ provider :: !OkfProvider,
model :: !(Maybe Text),
systemPrompt :: !(Maybe Text)
}
deriving stock (Generic, Eq, Show)
deriving anyclass (FromDhall)
-- | Profile-related settings: which registry @okf profile@ reads by default.
data ProfileSettings = ProfileSettings
{ registry :: !Text
}
deriving stock (Generic, Eq, Show)
deriving anyclass (FromDhall)
-- | The settings one agent-launching command can carry. Every field is
-- optional: an unset field means "this key claims nothing", which is what lets
-- a narrower key fall through to a broader one.
data AgentFieldSettings = AgentFieldSettings
{ provider :: !(Maybe OkfProvider),
model :: !(Maybe Text),
effort :: !(Maybe OkfEffort),
systemPrompt :: !(Maybe Text)
}
deriving stock (Generic, Eq, Show)
deriving anyclass (FromDhall)
-- | The @agent@ block: the same four fields as shared defaults, plus one
-- sub-record per agent-launching command.
data AgentSettings = AgentSettings
{ provider :: !(Maybe OkfProvider),
model :: !(Maybe Text),
effort :: !(Maybe OkfEffort),
systemPrompt :: !(Maybe Text),
assist :: !AgentFieldSettings
}
deriving stock (Generic, Eq, Show)
deriving anyclass (FromDhall)
-- | Project the shared defaults into the same shape as a per-command block, so
-- the resolver can walk both with one candidate builder.
agentSharedDefaults :: AgentSettings -> AgentFieldSettings
agentSharedDefaults AgentSettings {provider, model, effort, systemPrompt} =
AgentFieldSettings {provider, model, effort, systemPrompt}
emptyAgentFieldSettings :: AgentFieldSettings
emptyAgentFieldSettings =
AgentFieldSettings
{ provider = Nothing,
model = Nothing,
effort = Nothing,
systemPrompt = Nothing
}
defaultAgentSettings :: AgentSettings
defaultAgentSettings =
AgentSettings
{ provider = Nothing,
model = Nothing,
effort = Nothing,
systemPrompt = Nothing,
assist = emptyAgentFieldSettings
}
-- | The whole okf configuration.
data OkfConfig = OkfConfig
{ kit :: !KitSettings,
agent :: !AgentSettings,
profiles :: !ProfileSettings
}
deriving stock (Generic, Eq, Show)
deriving anyclass (FromDhall)
-- | The configuration record as it stood before the @agent@ block replaced
-- @assist@. Dhall decodes records strictly, so without this fallback changing
-- the record would stop every existing config file from loading.
data ConfigShapeWithoutAgent = ConfigShapeWithoutAgent
{ kit :: !KitSettings,
assist :: !LegacyAssistSettings,
profiles :: !ProfileSettings
}
deriving stock (Generic, Eq, Show)
deriving anyclass (FromDhall)
-- | The configuration record as okf 0.2.0.0 defined it, before @profiles@ was
-- added.
data ConfigShapeV020 = ConfigShapeV020
{ kit :: !KitSettings,
assist :: !LegacyAssistSettings
}
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)
-- | Which of the two layers a configuration file occupies. Any local value
-- beats any global one.
data ConfigScope = LocalScope | GlobalScope
deriving stock (Eq, Show)
-- | The file, if any, occupying each scope.
data ConfigScopes = ConfigScopes
{ localSource :: !(Maybe FilePath),
globalSource :: !(Maybe FilePath)
}
deriving stock (Eq, Show)
defaultOkfConfig :: OkfConfig
defaultOkfConfig =
OkfConfig
{ kit =
KitSettings
{ repoUrl = "https://github.com/shinzui/okf-kit.git",
providers = [ProviderClaude]
},
agent = defaultAgentSettings,
profiles = defaultProfileSettings
}
-- | The profile settings a config file that predates @profiles@ is given.
defaultProfileSettings :: ProfileSettings
defaultProfileSettings =
ProfileSettings
{ registry = defaultRegistryReference
}
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
-- | Resolve both configuration scopes independently, for the settings that
-- layer rather than replace.
--
-- Note the deliberate consequence of the local rule: @OKF_CONFIG@ replaces the
-- project file but does not suppress the global one, because it names /a/ file
-- rather than /the only/ file.
findConfigScopes :: IO ConfigScopes
findConfigScopes = do
mEnv <- lookupEnv okfConfigEnvVar
envExists <- maybe (pure False) doesFileExist mEnv
projectPath <- projectConfigPath
localSource <- if envExists then pure mEnv else firstExistingFile [projectPath]
xdgPath <- xdgConfigPath
dotPath <- dotConfigPath
globalSource <- firstExistingFile [xdgPath, dotPath]
pure ConfigScopes {localSource, globalSource}
firstExistingFile :: [FilePath] -> IO (Maybe FilePath)
firstExistingFile [] = pure Nothing
firstExistingFile (path : rest) = do
exists <- doesFileExist path
if exists then pure (Just path) else firstExistingFile 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 -> fmap (,configSource) <$> decodeConfigFile path
-- | Load the @agent@ block from each scope. 'Nothing' for a scope means that
-- scope has no configuration file, not that its file set nothing.
loadAgentScopes :: IO (Either Text (Maybe AgentSettings, Maybe AgentSettings))
loadAgentScopes = do
ConfigScopes {localSource, globalSource} <- findConfigScopes
localResult <- loadScope LocalScope localSource
case localResult of
Left err -> pure (Left err)
Right localAgent -> fmap (localAgent,) <$> loadScope GlobalScope globalSource
where
loadScope _scope Nothing = pure (Right Nothing)
loadScope scope (Just path) = do
decoded <- decodeConfigFile path
pure $ case decoded of
Left err ->
Left
( "Failed to load "
<> renderConfigScope scope
<> " config "
<> Text.pack path
<> ": "
<> err
)
Right config -> Right (Just (agent config))
-- | Decode one configuration file, trying each record shape okf has written, in
-- order from newest to oldest, and filling the missing pieces from defaults.
--
-- A file that predates the @agent@ block has its @assist@ block mapped onto the
-- per-command @agent.assist@ keys, so a user who never edits their file keeps
-- exactly the behaviour they have today. If every shape fails, the /first/
-- error is reported, because that message describes the schema the user should
-- be writing against.
decodeConfigFile :: FilePath -> IO (Either Text OkfConfig)
decodeConfigFile path = do
current <- tryDecode (Dhall.inputFile auto path)
case current of
Right config -> pure (Right config)
Left currentError -> do
withoutAgent <- tryDecode (Dhall.inputFile auto path)
case withoutAgent of
Right shape -> pure (Right (fromShapeWithoutAgent shape))
Left _withoutAgentError -> do
v020 <- tryDecode (Dhall.inputFile auto path)
pure $ case v020 of
Right shape -> Right (fromShapeV020 shape)
Left _v020Error -> Left currentError
where
tryDecode :: IO a -> IO (Either Text a)
tryDecode action =
(Right <$> action)
`catch` \(exception :: SomeException) ->
pure (Left (Text.pack (show exception)))
fromShapeWithoutAgent :: ConfigShapeWithoutAgent -> OkfConfig
fromShapeWithoutAgent ConfigShapeWithoutAgent {kit, assist, profiles} =
OkfConfig {kit, agent = agentSettingsFromAssist assist, profiles}
fromShapeV020 :: ConfigShapeV020 -> OkfConfig
fromShapeV020 ConfigShapeV020 {kit, assist} =
OkfConfig
{ kit,
agent = agentSettingsFromAssist assist,
profiles = defaultProfileSettings
}
-- | Carry a pre-@agent@ @assist@ block onto the per-command keys that replaced
-- it. The old block was per-command by nature, so it maps onto
-- @agent.assist.*@ rather than onto the shared defaults.
agentSettingsFromAssist :: LegacyAssistSettings -> AgentSettings
agentSettingsFromAssist LegacyAssistSettings {provider, model, systemPrompt} =
AgentSettings
{ provider = Nothing,
model = Nothing,
effort = Nothing,
systemPrompt = Nothing,
assist =
AgentFieldSettings
{ provider = Just provider,
model = model,
effort = Nothing,
systemPrompt = systemPrompt
}
}
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)"
renderConfigScope :: ConfigScope -> Text
renderConfigScope = \case
LocalScope -> "local"
GlobalScope -> "global"
-- | Human-readable dump of the effective configuration.
renderConfig :: OkfConfig -> Text
renderConfig
OkfConfig
{ kit = KitSettings {repoUrl, providers},
agent = agentSettings@AgentSettings {assist = agentAssist},
profiles = ProfileSettings {registry}
} =
Text.unlines
( [ "kit.repoUrl = " <> repoUrl,
"kit.providers = " <> renderProviders providers
]
<> renderAgentFields "agent." (agentSharedDefaults agentSettings)
<> renderAgentFields "agent.assist." agentAssist
<> ["profiles.registry = " <> registry]
)
renderAgentFields :: Text -> AgentFieldSettings -> [Text]
renderAgentFields keyPrefix AgentFieldSettings {provider, model, effort, systemPrompt} =
[ keyPrefix <> "provider = " <> maybe "(unset)" renderOkfProvider provider,
keyPrefix <> "model = " <> fromMaybe "(unset)" model,
keyPrefix <> "effort = " <> maybe "(unset)" renderOkfEffort effort,
keyPrefix <> "systemPrompt = " <> fromMaybe "(unset)" systemPrompt
]
renderProviders :: [OkfProvider] -> Text
renderProviders providers = "[" <> Text.intercalate ", " (map renderOkfProvider providers) <> "]"
renderOkfProvider :: OkfProvider -> Text
renderOkfProvider = \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,",
"-- and `okf config agent` for how each agent setting was resolved.",
"let Provider = < Claude | Codex >",
"",
"let Effort = < Minimal | Low | Medium | High | XHigh | Max >",
"",
"in { kit =",
" { repoUrl = \"https://github.com/shinzui/okf-kit.git\"",
" , providers = [ Provider.Claude ]",
" }",
" , agent =",
" -- Shared defaults for every agent-launching command.",
" { provider = None Provider",
" , model = None Text",
" , effort = None Effort",
" , systemPrompt = None Text",
" -- Per-command settings; these win over the shared defaults above.",
" , assist =",
" { provider = None Provider",
" , model = None Text",
" , effort = None Effort",
" , systemPrompt = None Text",
" }",
" }",
" , profiles =",
" { registry = \"" <> defaultRegistryReference <> "\"",
" }",
" }"
]