baikai-kit-0.4.0.0: src/Baikai/Kit/CodexConfig.hs
-- | Preserve the user's TOML text; parse both sides of every surgical edit.
module Baikai.Kit.CodexConfig
( codexConfigPath,
readSkillEntries,
addDisabledSkill,
removeDisabledSkill,
checkDisabledSkill,
checkRemoveDisabledSkill,
enableSkillsArgs,
)
where
import Baikai.Kit.Error (KitError (..))
import Baikai.Prelude
import Control.Exception (IOException, onException, try)
import Control.Monad (forM_, unless, when)
import Data.ByteString qualified as BS
import Data.Char (ord)
import Data.List (nub)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Encoding
import Numeric (showHex)
import System.Directory
( canonicalizePath,
createDirectoryIfMissing,
doesDirectoryExist,
doesFileExist,
getHomeDirectory,
getPermissions,
pathIsSymbolicLink,
readable,
removeFile,
renameFile,
setPermissions,
writable,
)
import System.Environment (lookupEnv)
import System.FilePath (takeDirectory, takeFileName, (</>))
import System.IO (hClose, openTempFile)
import Toml qualified
codexConfigPath :: IO FilePath
codexConfigPath = do
home <- getHomeDirectory
root <- fromMaybe (home </> ".codex") <$> lookupEnv "CODEX_HOME"
pure (root </> "config.toml")
readSkillEntries :: FilePath -> IO (Either KitError [(FilePath, Bool)])
readSkillEntries config = configTry config "" $ do
text <- readConfig config
pure $ do
table <- parseConfig text
traverse entry (configValues table)
where
entry (Toml.Table t) = do
p <- case value "path" t of
Just (Toml.Text p) -> pure (Text.unpack p)
_ -> Left "skills.config entry has no string path"
enabled <- case value "enabled" t of
Nothing -> Right True
Just (Toml.Bool b) -> Right b
_ -> Left "skills.config entry has no boolean enabled"
Right (p, enabled)
entry _ = Left "skills.config must contain tables"
-- | Validate without writing: True means we would add and own this entry.
checkDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)
checkDisabledSkill config skill = fmap (fmap (has _Just)) (prepareEdit True config skill)
checkRemoveDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)
checkRemoveDisabledSkill config skill = fmap (fmap (has _Just)) (prepareEdit False config skill)
addDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)
addDisabledSkill = editSkill True
removeDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)
removeDisabledSkill = editSkill False
editSkill :: Bool -> FilePath -> FilePath -> IO (Either KitError Bool)
editSkill adding config skill = do
prepared <- prepareEdit adding config skill
case prepared of
Left err -> pure (Left err)
Right Nothing -> pure (Right False)
Right (Just text) -> configTry config skill $ do
atomicWrite config text
pure (Right True)
prepareEdit :: Bool -> FilePath -> FilePath -> IO (Either KitError (Maybe Text))
prepareEdit adding config skill = configTry config skill $ do
linked <- either (const False) id <$> try @IOException (pathIsSymbolicLink config)
if linked
then pure (Left "config.toml is a symbolic link (possibly generated); refusing to replace it")
else do
exists <- doesFileExist config
permissions <- if exists then Just <$> getPermissions config else pure Nothing
case permissions of
Just perms | not (readable perms && writable perms) -> ioError (userError "config.toml is not readable and writable")
_ -> pure ()
checkParentWritable (takeDirectory config)
text <- readConfig config
case parseConfig text of
Left err -> pure (Left err)
Right old -> do
absolute <- canonicalizePath skill
matches <- traverse (matchesPath absolute) (configValues old)
let matchedEntries = [v | (v, True) <- zip (configValues old) matches]
pure $ do
selected <- case matchedEntries of
[] -> Right Nothing
[v@(Toml.Table t)] -> case value "enabled" t of
Just (Toml.Bool False) -> Right (Just v)
_
| adding -> Left "the user already enabled this skill; refusing to override that entry"
| otherwise -> Right Nothing
_ -> Left "multiple skills.config entries name this path"
case (adding, selected) of
(True, Just _) -> Right Nothing
(False, Nothing) -> Right Nothing
_ -> do
let newText = if adding then appendBlock text absolute else removeBlock text absolute
expected =
if adding
then configValues old ++ [disabledValue absolute]
else filter (\v -> Just v /= selected) (configValues old)
new <- parseConfig newText
unless (configValues new == expected && withoutConfig old == withoutConfig new) $
Left "the edit would change other TOML values; use array-of-table [[skills.config]] entries"
Right (Just newText)
where
matchesPath absolute (Toml.Table t) = case value "path" t of
Just (Toml.Text p) -> (== absolute) <$> canonicalizePath (Text.unpack p)
_ -> pure False
matchesPath _ _ = pure False
checkParentWritable :: FilePath -> IO ()
checkParentWritable path = do
exists <- doesDirectoryExist path
if exists
then do
permissions <- getPermissions path
unless (writable permissions) (ioError (userError "Codex config directory is not writable"))
else do
let parent = takeDirectory path
when (parent == path || parent == "/") (ioError (userError "Codex config has no writable parent"))
checkParentWritable parent
readConfig :: FilePath -> IO Text
readConfig config = do
exists <- doesFileExist config
if not exists
then pure ""
else do
bytes <- BS.readFile config
either (ioError . userError . show) pure (Encoding.decodeUtf8' bytes)
parseConfig :: Text -> Either Text Toml.Table
parseConfig text = do
t <- either (Left . Text.pack) (Right . Toml.forgetTableAnns) (Toml.parse text)
case value "skills" t of
Nothing -> Right t
Just (Toml.Table skills) -> case value "config" skills of
Nothing -> Right t
Just (Toml.List _) -> Right t
_ -> Left "skills.config is not an array"
_ -> Left "skills is not a table"
value :: Text -> Toml.Table -> Maybe Toml.Value
value key (Toml.MkTable t) = snd <$> Map.lookup key t
configValues :: Toml.Table -> [Toml.Value]
configValues t = case value "skills" t of
Just (Toml.Table skills) -> case value "config" skills of
Just (Toml.List values) -> values
_ -> []
_ -> []
withoutConfig :: Toml.Table -> Toml.Table
withoutConfig (Toml.MkTable t) = Toml.MkTable $ Map.update clean "skills" t
where
clean (_, Toml.Table (Toml.MkTable skills)) =
let rest = Map.delete "config" skills
in if Map.null rest then Nothing else Just ((), Toml.Table (Toml.MkTable rest))
clean other = Just other
disabledValue :: FilePath -> Toml.Value
disabledValue skill =
Toml.Table
( Toml.MkTable
( Map.fromList
[("path", ((), Toml.Text (Text.pack skill))), ("enabled", ((), Toml.Bool False))]
)
)
disabledBlock :: FilePath -> Text
disabledBlock skill = "[[skills.config]]\npath = " <> tomlString (Text.pack skill) <> "\nenabled = false\n"
-- Mark the separator so uninstall restores even a seed with no final newline.
appendBlock :: Text -> FilePath -> Text
appendBlock text skill = text <> "\n# baikai-kit begin\n" <> disabledBlock skill <> "# baikai-kit end\n"
removeBlock :: Text -> FilePath -> Text
removeBlock text skill =
let marked = go "" (Text.splitOn "\n# baikai-kit begin\n" text)
in if marked /= text then marked else removeUnmarked text skill
where
go prefix [] = prefix
go prefix [lastPart] = prefix <> lastPart
go prefix (part : block : remaining) =
let (body, suffix) = Text.breakOn "# baikai-kit end\n" block
matches = case parseConfig body of
Right table -> configValues table == [disabledValue skill]
Left _ -> False
in if matches && not (Text.null suffix)
then
prefix
<> part
<> Text.drop (Text.length "# baikai-kit end\n") suffix
<> Text.concat ["\n# baikai-kit begin\n" <> r | r <- remaining]
else go (prefix <> part <> "\n# baikai-kit begin\n") (block : remaining)
-- A user may remove our delimiter comments. Locate the table block; the
-- caller still verifies that removing it changes exactly one parsed entry.
removeUnmarked :: Text -> FilePath -> Text
removeUnmarked text skill = go [] (Text.splitOn "\n" text)
where
go before [] = Text.intercalate "\n" before
go before (line : rest)
| Text.strip (Text.takeWhile (/= '#') line) == "[[skills.config]]" =
let (body, after) = break (Text.isPrefixOf "[" . Text.stripStart) rest
block = Text.intercalate "\n" (line : body)
matches = case parseConfig block of
Right table -> case configValues table of
[Toml.Table entry] ->
value "path" entry == Just (Toml.Text (Text.pack skill))
&& value "enabled" entry == Just (Toml.Bool False)
_ -> False
Left _ -> False
in if matches
then Text.intercalate "\n" (before ++ after)
else go (before ++ [line]) rest
| otherwise = go (before ++ [line]) rest
atomicWrite :: FilePath -> Text -> IO ()
atomicWrite path text = do
let dir = takeDirectory path
createDirectoryIfMissing True dir
exists <- doesFileExist path
permissions <- if exists then Just <$> getPermissions path else pure Nothing
(temp, handle) <- openTempFile dir (takeFileName path <> ".baikai-kit-tmp")
( do
BS.hPut handle (Encoding.encodeUtf8 text)
hClose handle
forM_ permissions (setPermissions temp)
renameFile temp path
)
`onException` (hClose handle >> removeFile temp)
configTry :: FilePath -> FilePath -> IO (Either Text a) -> IO (Either KitError a)
configTry config skill action = do
result <- try @IOException action
pure $ case result of
Left e -> Left (failure (Text.pack (show e)))
Right (Left reason) -> Left (failure reason)
Right (Right v) -> Right v
where
failure reason =
KitCodexConfigUnusable
config
( reason
<> "\nAdd this block by hand:\n"
<> disabledBlock skill
<> "Use --shared to install without the entry. --accept-shared-codex applies to agents that cannot be isolated."
)
enableSkillsArgs :: [FilePath] -> [Text]
enableSkillsArgs [] = []
enableSkillsArgs skills =
[ "-c",
"skills.config=["
<> Text.intercalate
","
["{path=" <> tomlString (Text.pack p) <> ",enabled=true}" | p <- nub skills]
<> "]"
]
tomlString :: Text -> Text
tomlString input = "\"" <> Text.concatMap escape input <> "\""
where
escape '"' = "\\\""
escape '\\' = "\\\\"
escape c
| ord c < 32 || ord c == 127 =
let digits = showHex (ord c) ""
in "\\u" <> Text.pack (replicate (4 - length digits) '0' ++ digits)
escape c = Text.singleton c