baikai-kit-0.1.0.1: src/Baikai/Kit/Install.hs
module Baikai.Kit.Install
( loadManifest,
loadManifestMaybe,
lookupItem,
installItem,
uninstallItem,
uninstallOutcomes,
renderUninstallReport,
RemovalOutcome (..),
updateKit,
listAvailable,
stripYamlFrontmatter,
)
where
import Baikai.AgentAssets
( CodexCustomAgent (..),
agentTargetPath,
codexCustomAgentToml,
skillTargetPath,
)
import Baikai.Interactive (InteractiveProvider (..), InteractiveScope (InteractiveProjectScope))
import Baikai.Kit.Config (KitConfig, KitScope (..), kitCacheDir, providerAgentsBase, providerLabel, scopeLabel, sidecarFileName)
import Baikai.Kit.Manifest
( AgentEntry,
KitItem (..),
KitItemKind (..),
KitManifest (..),
SkillEntry,
itemKind,
kitItemKind,
)
import Baikai.Kit.Path (safeItemName, safeRelativePath)
import Baikai.Kit.Repo (PullResult (..), ensureKitRepo, pullKitRepo)
import Baikai.Kit.Sidecar (computeKitHash, newSidecarMeta, sidecarPath)
import Baikai.Prelude
import Control.Exception (IOException, catch, throwIO, try)
import Control.Monad (forM, forM_, unless, when)
import Data.Aeson (eitherDecodeFileStrict', encode)
import Data.ByteString.Lazy qualified as LBS
import Data.List (find, nub)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.Text.IO qualified as Text.IO
import System.Directory
( createDirectoryIfMissing,
doesDirectoryExist,
doesFileExist,
removeDirectoryRecursive,
removeFile,
renameFile,
)
import System.Exit (exitFailure)
import System.FilePath (takeDirectory, takeFileName, (</>))
import System.IO (hPutStrLn, stderr)
data PlannedWrite = PlannedWrite
{ destination :: !FilePath,
content :: !WriteContent
}
data WriteContent
= CopyFrom !FilePath
| WriteBytes !LBS.ByteString
data RemovalOutcome = RemovalOutcome
{ provider :: !InteractiveProvider,
skillRemoved :: !Bool,
agentRemoved :: !Bool,
sidecarRemoved :: !Bool
}
deriving stock (Eq, Generic, Show)
loadManifest :: FilePath -> IO KitManifest
loadManifest repoDir = do
let manifestPath = repoDir </> "kit.json"
exists <- doesFileExist manifestPath
unless exists $ do
hPutStrLn stderr "Error: kit.json not found in kit repository."
exitFailure
result <- eitherDecodeFileStrict' manifestPath
case result of
Left err -> do
hPutStrLn stderr $ "Error: Failed to parse kit.json: " <> err
exitFailure
Right manifest -> pure manifest
loadManifestMaybe :: FilePath -> IO (Maybe KitManifest)
loadManifestMaybe "" = pure Nothing
loadManifestMaybe cacheDir = do
let manifestPath = cacheDir </> "kit.json"
exists <- doesFileExist manifestPath
if not exists
then pure Nothing
else do
result <- eitherDecodeFileStrict' manifestPath
case result of
Left err -> do
hPutStrLn stderr $ "Warning: failed to parse kit.json: " <> err
pure Nothing
Right manifest -> pure (Just manifest)
lookupItem :: Text -> KitManifest -> Maybe KitItem
lookupItem n manifest =
case find (\entry -> entry ^. #name == n) (manifest ^. #skills) of
Just skill -> Just (KitSkillItem skill)
Nothing -> KitAgentItem <$> find (\entry -> entry ^. #name == n) (manifest ^. #agents)
installItem :: KitConfig -> Text -> KitScope -> IO ()
installItem config itemN scope = do
repoDir <- ensureKitRepo config
manifest <- loadManifest repoDir
case lookupItem itemN manifest of
Nothing -> do
hPutStrLn stderr $ "Error: '" <> Text.unpack itemN <> "' not found in kit manifest."
exitFailure
Just item -> do
result <- try @IOException (doInstall config repoDir item scope)
case result of
Right () ->
Text.IO.putStrLn $
"Installed " <> itemKind item <> " '" <> itemN <> "' to " <> scopeLabel scope <> " scope."
Left e -> do
hPutStrLn stderr $ "Error: install failed, no changes were made: " <> show e
exitFailure
uninstallItem :: KitConfig -> Text -> KitScope -> IO ()
uninstallItem config n scope = do
outcomes <- uninstallOutcomes config n scope
Text.IO.putStrLn (renderUninstallReport n scope outcomes)
uninstallOutcomes :: KitConfig -> Text -> KitScope -> IO [RemovalOutcome]
uninstallOutcomes config n scope = do
safeName <- requireSafe "item name" (safeItemName n)
forM (config ^. #providers) $ \provider -> do
providerBase <- providerAgentsBase config provider scope
skillRemoved <- removeIfDirectory (skillTarget config provider providerBase safeName)
agentRemoved <- removeIfFile (agentTarget config provider providerBase safeName)
sidecarRemoved <- removeIfFile (agentSidecarTarget config provider providerBase safeName)
pure RemovalOutcome {provider, skillRemoved, agentRemoved, sidecarRemoved}
renderUninstallReport :: Text -> KitScope -> [RemovalOutcome] -> Text
renderUninstallReport n scope outcomes
| any assetRemoved outcomes =
"Uninstalled "
<> Text.intercalate "+" removedKinds
<> " '"
<> n
<> "' from "
<> scopeLabel scope
<> " scope ("
<> Text.intercalate "," removedProviders
<> ")."
| any (^. #sidecarRemoved) outcomes =
"Removed stale kit metadata for '" <> n <> "' from " <> scopeLabel scope <> " scope."
| otherwise =
"'" <> n <> "' is not installed in " <> scopeLabel scope <> " scope."
where
assetRemoved outcome = outcome ^. #skillRemoved || outcome ^. #agentRemoved
removedKinds =
nub $
["skill" | any (^. #skillRemoved) outcomes]
++ ["agent" | any (^. #agentRemoved) outcomes]
removedProviders = map (providerLabel . view #provider) (filter assetRemoved outcomes)
updateKit :: KitConfig -> Maybe Text -> IO ()
updateKit config mName = do
cacheDir <- kitCacheDir config
exists <- doesDirectoryExist cacheDir
if exists
then do
result <- pullKitRepo config cacheDir
case result of
PullSucceeded -> Text.IO.putStrLn "Kit repository updated."
PullFailed err -> do
hPutStrLn stderr $ "Error: failed to update kit repository: " <> Text.unpack err
hPutStrLn stderr "The cached copy is unchanged; installed items were not reinstalled."
exitFailure
else do
_ <- ensureKitRepo config
Text.IO.putStrLn "Kit repository cloned."
manifest <- loadManifest =<< kitCacheDir config
case mName of
Just n -> do
reinstallIfPresent config n UserScope manifest
reinstallIfPresent config n ProjectScope manifest
Nothing -> reinstallAllPresent config manifest
listAvailable :: KitConfig -> IO ()
listAvailable config = do
repoDir <- ensureKitRepo config
manifest <- loadManifest repoDir
let sk = manifest ^. #skills
ag = manifest ^. #agents
if null sk && null ag
then Text.IO.putStrLn "No items available in the kit."
else do
unless (null sk) $ do
Text.IO.putStrLn "Skills:"
let maxLen = maximum $ map (Text.length . view #name) sk
mapM_ (printEntry maxLen . skillNameDesc) sk
unless (null ag) $ do
unless (null sk) (Text.IO.putStrLn "")
Text.IO.putStrLn "Agents:"
let maxLen = maximum $ map (Text.length . view #name) ag
mapM_ (printEntry maxLen . agentNameDesc) ag
where
printEntry maxLen (n, desc) =
Text.IO.putStrLn $ " " <> Text.justifyLeft (maxLen + 2) ' ' n <> desc
doInstall :: KitConfig -> FilePath -> KitItem -> KitScope -> IO ()
doInstall config repoDir item scope =
planInstall config repoDir item scope >>= executePlan
planInstall :: KitConfig -> FilePath -> KitItem -> KitScope -> IO [PlannedWrite]
planInstall config repoDir item@(KitSkillItem entry) scope = do
safeName <- requireSafe "skill name" (safeItemName (entry ^. #name))
safePath <- requireSafe "skill path" (safeRelativePath (entry ^. #path))
safeFiles <- traverse (requireSafe "skill file" . safeRelativePath) (entry ^. #files)
let sourceDir = repoDir </> safePath
forM_ safeFiles $ \file -> requireSourceFile (sourceDir </> file)
hashStr <- computeKitHash sourceDir (map Text.pack safeFiles)
meta <- newSidecarMeta item hashStr
fmap concat $
forM (config ^. #providers) $ \provider -> do
targetBase <- providerAgentsBase config provider scope
let targetDir = skillTarget config provider targetBase safeName
fileWrites =
[ PlannedWrite
{ destination = targetDir </> file,
content = CopyFrom (sourceDir </> file)
}
| file <- safeFiles
]
sidecarWrite =
PlannedWrite
{ destination = sidecarPath provider (kitItemKind item) (Text.pack safeName) targetBase (sidecarFileName config),
content = WriteBytes (encode meta)
}
pure (fileWrites ++ [sidecarWrite])
planInstall config repoDir item@(KitAgentItem entry) scope = do
safeName <- requireSafe "agent name" (safeItemName (entry ^. #name))
(sourceBase, relFiles, primarySource) <- safeAgentSources repoDir entry
forM_ relFiles $ \file -> requireSourceFile (sourceBase </> file)
hashStr <- computeKitHash sourceBase (map Text.pack relFiles)
meta <- newSidecarMeta item hashStr
body <- Text.IO.readFile primarySource
fmap concat $
forM (config ^. #providers) $ \provider -> do
targetBase <- providerAgentsBase config provider scope
let dstFile = agentTarget config provider targetBase safeName
agentWrite =
case provider of
InteractiveClaude ->
PlannedWrite {destination = dstFile, content = CopyFrom primarySource}
InteractiveCodex ->
PlannedWrite
{ destination = dstFile,
content = WriteBytes (LBS.fromStrict (Text.Encoding.encodeUtf8 (agentAsCodexToml entry body)))
}
sidecarWrite =
PlannedWrite
{ destination = sidecarPath provider (kitItemKind item) (Text.pack safeName) targetBase (sidecarFileName config),
content = WriteBytes (encode meta)
}
pure [agentWrite, sidecarWrite]
safeAgentSources :: FilePath -> AgentEntry -> IO (FilePath, [FilePath], FilePath)
safeAgentSources repoDir entry =
case entry ^. #files of
Just files -> do
safePath <- requireSafe "agent path" (safeRelativePath (entry ^. #path))
safeFiles <- traverse (requireSafe "agent file" . safeRelativePath) files
case safeFiles of
[] -> do
hPutStrLn stderr $ "Error: agent '" <> Text.unpack (entry ^. #name) <> "' has no source files."
exitFailure
primaryRel : _ -> do
let sourceBase = repoDir </> safePath
pure (sourceBase, safeFiles, sourceBase </> primaryRel)
Nothing -> do
safePath <- requireSafe "agent path" (safeRelativePath (entry ^. #path))
let fileName = takeFileName safePath
pure (repoDir </> takeDirectory safePath, [fileName], repoDir </> safePath)
requireSourceFile :: FilePath -> IO ()
requireSourceFile path = do
exists <- doesFileExist path
unless exists (ioError (userError ("source file does not exist: " <> path)))
executePlan :: [PlannedWrite] -> IO ()
executePlan writes = do
tempPaths <- phaseOne [] writes
forM_ tempPaths $ \(temp, final) -> renameFile temp final
where
phaseOne temps [] = pure (reverse temps)
phaseOne temps (PlannedWrite {destination, content} : rest) = do
let temp = destination <> ".baikai-kit-tmp"
result <-
try @IOException $ do
createDirectoryIfMissing True (takeDirectory destination)
writeTemp content temp
pure (temp, destination)
case result of
Left e -> cleanupTemps temps >> throwIO e
Right tempPair ->
phaseOne (tempPair : temps) rest
`catch` \(e :: IOException) -> cleanupTemps (tempPair : temps) >> throwIO e
writeTemp (CopyFrom src) temp = LBS.readFile src >>= LBS.writeFile temp
writeTemp (WriteBytes bytes) temp = LBS.writeFile temp bytes
cleanupTemps temps =
forM_ temps $ \(temp, _) -> do
_ <- try @IOException (removeFile temp)
pure ()
requireSafe :: Text -> Either Text a -> IO a
requireSafe what = \case
Right a -> pure a
Left reason -> do
hPutStrLn stderr $ "Error: unsafe " <> Text.unpack what <> ": " <> Text.unpack reason
exitFailure
reinstallIfPresent :: KitConfig -> Text -> KitScope -> KitManifest -> IO ()
reinstallIfPresent config n scope manifest = do
installed <- isInstalled config n scope
when installed $
case lookupItem n manifest of
Nothing -> pure ()
Just item -> do
repoDir <- kitCacheDir config
doInstall config repoDir item scope
Text.IO.putStrLn $ "Updated '" <> n <> "' (" <> scopeLabel scope <> ")"
reinstallAllPresent :: KitConfig -> KitManifest -> IO ()
reinstallAllPresent config manifest = do
let allNames =
map (view #name) (manifest ^. #skills)
++ map (view #name) (manifest ^. #agents)
repoDir <- kitCacheDir config
updated <- fmap sum $ forM allNames $ \n -> do
userInstalled <- isInstalled config n UserScope
projectInstalled <- isInstalled config n ProjectScope
let count = (if userInstalled then 1 else 0) + (if projectInstalled then 1 else 0) :: Int
when userInstalled $
case lookupItem n manifest of
Nothing -> pure ()
Just item -> doInstall config repoDir item UserScope
when projectInstalled $
case lookupItem n manifest of
Nothing -> pure ()
Just item -> doInstall config repoDir item ProjectScope
pure count
Text.IO.putStrLn $ "Updated " <> Text.pack (show updated) <> " item(s)."
isInstalled :: KitConfig -> Text -> KitScope -> IO Bool
isInstalled config n scope = do
safeName <- requireSafe "item name" (safeItemName n)
results <- forM (config ^. #providers) $ \provider -> do
providerBase <- providerAgentsBase config provider scope
skillExists <- doesDirectoryExist (skillTarget config provider providerBase safeName)
agentExists <- doesFileExist (agentTarget config provider providerBase safeName)
pure (skillExists || agentExists)
pure (or results)
removeIfDirectory :: FilePath -> IO Bool
removeIfDirectory dir = do
exists <- doesDirectoryExist dir
when exists (removeDirectoryRecursive dir)
pure exists
removeIfFile :: FilePath -> IO Bool
removeIfFile file = do
exists <- doesFileExist file
when exists (removeFile file)
pure exists
skillTarget :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath
skillTarget _config provider targetBase n =
targetBase </> skillTargetPath provider InteractiveProjectScope n
agentTarget :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath
agentTarget _config provider targetBase n =
targetBase </> agentTargetPath provider InteractiveProjectScope n
agentSidecarTarget :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath
agentSidecarTarget config provider targetBase n =
sidecarPath provider AgentKind (Text.pack n) targetBase (sidecarFileName config)
skillNameDesc :: SkillEntry -> (Text, Text)
skillNameDesc entry = (entry ^. #name, entry ^. #description)
agentNameDesc :: AgentEntry -> (Text, Text)
agentNameDesc entry = (entry ^. #name, entry ^. #description)
agentAsCodexToml :: AgentEntry -> Text -> Text
agentAsCodexToml entry body =
codexCustomAgentToml
CodexCustomAgent
{ name = entry ^. #name,
description = entry ^. #description,
developerInstructions = stripYamlFrontmatter body
}
stripYamlFrontmatter :: Text -> Text
stripYamlFrontmatter input =
case map dropCr (Text.splitOn "\n" input) of
"---" : rest
| (_, _ : body) <- break (== "---") rest ->
Text.intercalate "\n" body
_ -> input
where
dropCr = Text.dropWhileEnd (== '\r')