packages feed

baikai-kit-0.1.0.1: src/Baikai/Kit/Sidecar.hs

module Baikai.Kit.Sidecar
  ( SidecarMeta (..),
    computeKitHash,
    newSidecarMeta,
    sidecarPath,
    readSidecar,
    writeSidecar,
  )
where

import Baikai.AgentAssets (AgentAssetProvider, agentTargetPath, skillTargetPath)
import Baikai.Interactive (InteractiveScope (InteractiveProjectScope))
import Baikai.Kit.Manifest (KitItem, KitItemKind (..), itemKind, itemName, itemVersion, kitItemKind)
import Baikai.Kit.Path (safeRelativePath)
import Baikai.Prelude
import Crypto.Hash (Digest, SHA256)
import Crypto.Hash qualified as Hash
import Data.Aeson (eitherDecodeFileStrict', encode)
import Data.Binary.Put (putWord64be, runPut)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.List (sort)
import Data.Text qualified as Text
import Data.Text.Encoding qualified as Text.Encoding
import Data.Time.Clock (getCurrentTime)
import Data.Time.Format (defaultTimeLocale, formatTime)
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.FilePath (dropExtension, takeDirectory, (</>))
import System.IO (hPutStrLn, stderr)

data SidecarMeta = SidecarMeta
  { name :: !Text,
    kind :: !Text,
    version :: !(Maybe Text),
    hash :: !Text,
    installedAt :: !Text
  }
  deriving stock (Eq, Generic, Show)
  deriving anyclass (FromJSON, ToJSON)

computeKitHash :: FilePath -> [Text] -> IO Text
computeKitHash baseDir relFiles = do
  chunks <- mapM (readOne baseDir) (sort relFiles)
  let digest = Hash.hash (BS.concat chunks) :: Digest SHA256
      hex = Text.pack (show digest)
  pure ("sha256:" <> hex)
  where
    readOne :: FilePath -> Text -> IO BS.ByteString
    readOne dir rel = do
      safeRel <- either (ioError . userError . Text.unpack) pure (safeRelativePath rel)
      content <- BS.readFile (dir </> safeRel)
      let pathBytes = Text.Encoding.encodeUtf8 (Text.pack safeRel)
          lenBytes = LBS.toStrict (runPut (putWord64be (fromIntegral (BS.length content))))
      pure $ BS.concat [pathBytes, BS.singleton 0x00, lenBytes, content, BS.singleton 0x00]

sidecarPath :: AgentAssetProvider -> KitItemKind -> Text -> FilePath -> Text -> FilePath
sidecarPath provider SkillKind itemName' targetBase sidecarName =
  targetBase
    </> skillTargetPath provider InteractiveProjectScope (Text.unpack itemName')
    </> Text.unpack sidecarName
sidecarPath provider AgentKind itemName' targetBase sidecarName =
  targetBase
    </> dropExtension (agentTargetPath provider InteractiveProjectScope (Text.unpack itemName'))
      <> Text.unpack sidecarName

readSidecar :: FilePath -> IO (Maybe SidecarMeta)
readSidecar p = do
  exists <- doesFileExist p
  if not exists
    then pure Nothing
    else do
      result <- eitherDecodeFileStrict' p
      case result of
        Right meta -> pure (Just meta)
        Left err -> do
          hPutStrLn stderr $ "Warning: failed to parse sidecar " <> p <> ": " <> err
          pure Nothing

writeSidecar :: AgentAssetProvider -> KitItem -> FilePath -> Text -> Text -> IO ()
writeSidecar provider item targetBase sidecarName hashStr = do
  meta <- newSidecarMeta item hashStr
  let out = sidecarPath provider (kitItemKind item) (itemName item) targetBase sidecarName
  createDirectoryIfMissing True (takeDirectory out)
  LBS.writeFile out (encode meta)

newSidecarMeta :: KitItem -> Text -> IO SidecarMeta
newSidecarMeta item hashStr = do
  now <- getCurrentTime
  let stamp = Text.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" now)
  pure
    SidecarMeta
      { name = itemName item,
        kind = itemKind item,
        version = itemVersion item,
        hash = hashStr,
        installedAt = stamp
      }