packages feed

baikai-kit (empty) → 0.1.0.1

raw patch · 13 files changed

+1691/−0 lines, 13 filesdep +aesondep +baikaidep +baikai-kit

Dependencies added: aeson, baikai, baikai-kit, base, binary, bytestring, crypton, directory, filepath, optparse-applicative, process, tasty, tasty-hunit, temporary, text, time

Files

+ LICENSE view
@@ -0,0 +1,26 @@+Copyright (c) 2026 Nadeem Bitar++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice, this+   list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its contributors+   may be used to endorse or promote products derived from this software+   without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ baikai-kit.cabal view
@@ -0,0 +1,78 @@+cabal-version: 3.4+name:          baikai-kit+version:       0.1.0.1+synopsis:      Shared kit installer for AI-agent skills and subagents+description:+  Shared implementation of kit listing, installation, update, uninstall,+  status, and discovery helpers for command-line tools that install local+  AI-agent skills and subagents.++category:      AI+license:       BSD-3-Clause+license-file:  LICENSE+author:        Nadeem Bitar+maintainer:    nadeem@gmail.com+copyright:     (c) 2026 Nadeem Bitar+build-type:    Simple++common common-options+  ghc-options:+    -Wall -Wcompat -Widentities -Wincomplete-uni-patterns+    -Wincomplete-record-updates -Wredundant-constraints+    -fhide-source-paths -Wmissing-export-lists -Wpartial-fields+    -Wmissing-deriving-strategies++  default-language:   GHC2024+  default-extensions:+    DeriveAnyClass+    DuplicateRecordFields+    OverloadedLabels+    OverloadedStrings++library+  import:          common-options+  hs-source-dirs:  src+  exposed-modules:+    Baikai.Kit+    Baikai.Kit.Command+    Baikai.Kit.Config+    Baikai.Kit.Install+    Baikai.Kit.Manifest+    Baikai.Kit.Path+    Baikai.Kit.Repo+    Baikai.Kit.Session+    Baikai.Kit.Sidecar+    Baikai.Kit.Status++  build-depends:+    , aeson+    , baikai                ^>=0.3.0+    , base                  >=4.20   && <5+    , binary+    , bytestring+    , crypton+    , directory+    , filepath+    , optparse-applicative+    , process+    , text                  ^>=2.1+    , time++test-suite baikai-kit-test+  import:         common-options+  type:           exitcode-stdio-1.0+  hs-source-dirs: test+  main-is:        Main.hs+  ghc-options:    -threaded -with-rtsopts=-N+  build-depends:+    , aeson+    , baikai+    , baikai-kit+    , base+    , bytestring+    , directory+    , filepath+    , tasty+    , tasty-hunit+    , temporary+    , text
+ src/Baikai/Kit.hs view
@@ -0,0 +1,22 @@+module Baikai.Kit+  ( module Baikai.Kit.Command,+    module Baikai.Kit.Config,+    module Baikai.Kit.Install,+    module Baikai.Kit.Manifest,+    module Baikai.Kit.Path,+    module Baikai.Kit.Repo,+    module Baikai.Kit.Session,+    module Baikai.Kit.Sidecar,+    module Baikai.Kit.Status,+  )+where++import Baikai.Kit.Command+import Baikai.Kit.Config+import Baikai.Kit.Install+import Baikai.Kit.Manifest+import Baikai.Kit.Path+import Baikai.Kit.Repo+import Baikai.Kit.Session+import Baikai.Kit.Sidecar+import Baikai.Kit.Status
+ src/Baikai/Kit/Command.hs view
@@ -0,0 +1,60 @@+module Baikai.Kit.Command+  ( KitCommand (..),+    kitCommandParser,+    runKit,+  )+where++import Baikai.Kit.Config (KitConfig, KitScope (..))+import Baikai.Kit.Install (installItem, listAvailable, uninstallItem, updateKit)+import Baikai.Kit.Status (kitStatus)+import Baikai.Prelude+import Options.Applicative++data KitCommand+  = KitList+  | KitInstall !Text !KitScope+  | KitUpdate !(Maybe Text)+  | KitUninstall !Text !KitScope+  | KitStatus+  deriving stock (Show)++kitCommandParser :: Parser KitCommand+kitCommandParser =+  hsubparser+    ( command "list" (info (pure KitList) (progDesc "List available skills and subagents"))+        <> command "install" (info installParser (progDesc "Install a skill or subagent"))+        <> command "update" (info updateParser (progDesc "Update installed skills and subagents"))+        <> command "uninstall" (info uninstallParser (progDesc "Uninstall a skill or subagent"))+        <> command "status" (info (pure KitStatus) (progDesc "Show installed skills and subagents"))+    )+    <|> pure KitList++runKit :: KitConfig -> KitCommand -> IO ()+runKit config = \case+  KitList -> listAvailable config+  KitInstall n scope -> installItem config n scope+  KitUpdate n -> updateKit config n+  KitUninstall n scope -> uninstallItem config n scope+  KitStatus -> kitStatus config++installParser :: Parser KitCommand+installParser =+  KitInstall+    <$> strArgument (metavar "NAME" <> help "Name of the skill or subagent to install")+    <*> scopeParser "Install to project scope instead of user scope"++updateParser :: Parser KitCommand+updateParser =+  KitUpdate+    <$> optional (strArgument (metavar "NAME" <> help "Name of a specific item to update (default: all)"))++uninstallParser :: Parser KitCommand+uninstallParser =+  KitUninstall+    <$> strArgument (metavar "NAME" <> help "Name of the skill or subagent to uninstall")+    <*> scopeParser "Uninstall from project scope instead of user scope"++scopeParser :: String -> Parser KitScope+scopeParser helpText =+  flag UserScope ProjectScope (long "project" <> help helpText)
+ src/Baikai/Kit/Config.hs view
@@ -0,0 +1,67 @@+module Baikai.Kit.Config+  ( KitConfig (..),+    KitScope (..),+    kitCacheDir,+    userAgentsDir,+    projectAgentsDir,+    resolveAgentsBase,+    providerAgentsBase,+    providerLabel,+    sidecarFileName,+    scopeLabel,+  )+where++import Baikai.AgentAssets (AgentAssetProvider)+import Baikai.Interactive (InteractiveProvider (..))+import Baikai.Prelude+import Data.Text qualified as Text+import System.Directory (getCurrentDirectory, getHomeDirectory)+import System.FilePath ((</>))++data KitConfig = KitConfig+  { toolName :: !Text,+    repoUrl :: !Text,+    providers :: ![AgentAssetProvider]+  }+  deriving stock (Generic, Show)++data KitScope+  = UserScope+  | ProjectScope+  deriving stock (Eq, Ord, Show)++kitCacheDir :: KitConfig -> IO FilePath+kitCacheDir config = do+  home <- getHomeDirectory+  pure (home </> ".cache" </> Text.unpack (config ^. #toolName) </> "kit")++userAgentsDir :: KitConfig -> IO FilePath+userAgentsDir config = do+  home <- getHomeDirectory+  pure (home </> ".config" </> Text.unpack (config ^. #toolName) </> "agents")++projectAgentsDir :: KitConfig -> IO FilePath+projectAgentsDir config = do+  cwd <- getCurrentDirectory+  pure (cwd </> "." <> Text.unpack (config ^. #toolName) </> "agents")++resolveAgentsBase :: KitConfig -> KitScope -> IO FilePath+resolveAgentsBase config UserScope = userAgentsDir config+resolveAgentsBase config ProjectScope = projectAgentsDir config++providerAgentsBase :: KitConfig -> AgentAssetProvider -> KitScope -> IO FilePath+providerAgentsBase config InteractiveClaude scope = resolveAgentsBase config scope+providerAgentsBase _config InteractiveCodex UserScope = getHomeDirectory+providerAgentsBase _config InteractiveCodex ProjectScope = getCurrentDirectory++providerLabel :: AgentAssetProvider -> Text+providerLabel InteractiveClaude = "claude"+providerLabel InteractiveCodex = "codex"++sidecarFileName :: KitConfig -> Text+sidecarFileName config = "." <> (config ^. #toolName) <> "-kit.json"++scopeLabel :: KitScope -> Text+scopeLabel UserScope = "user"+scopeLabel ProjectScope = "project"
+ src/Baikai/Kit/Install.hs view
@@ -0,0 +1,414 @@+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')
+ src/Baikai/Kit/Manifest.hs view
@@ -0,0 +1,84 @@+module Baikai.Kit.Manifest+  ( AgentEntry (..),+    KitItem (..),+    KitItemKind (..),+    KitManifest (..),+    SkillEntry (..),+    agentSources,+    itemKind,+    itemName,+    itemVersion,+    kitItemKind,+    kindLabel,+  )+where++import Baikai.Prelude+import Data.Text qualified as Text+import System.FilePath (takeFileName, (</>))++data KitManifest = KitManifest+  { version :: !Int,+    skills :: ![SkillEntry],+    agents :: ![AgentEntry]+  }+  deriving stock (Generic, Show)+  deriving anyclass (FromJSON)++data SkillEntry = SkillEntry+  { name :: !Text,+    description :: !Text,+    version :: !(Maybe Text),+    path :: !Text,+    files :: ![Text]+  }+  deriving stock (Generic, Show)+  deriving anyclass (FromJSON)++data AgentEntry = AgentEntry+  { name :: !Text,+    description :: !Text,+    version :: !(Maybe Text),+    path :: !Text,+    files :: !(Maybe [Text])+  }+  deriving stock (Generic, Show)+  deriving anyclass (FromJSON)++data KitItem+  = KitSkillItem !SkillEntry+  | KitAgentItem !AgentEntry+  deriving stock (Generic, Show)++data KitItemKind+  = SkillKind+  | AgentKind+  deriving stock (Eq, Ord, Show)++agentSources :: AgentEntry -> [(FilePath, FilePath)]+agentSources entry =+  case entry ^. #files of+    Just fs -> [(Text.unpack (entry ^. #path) </> Text.unpack f, Text.unpack f) | f <- fs]+    Nothing ->+      let source = Text.unpack (entry ^. #path)+       in [(source, takeFileName source)]++itemName :: KitItem -> Text+itemName (KitSkillItem entry) = entry ^. #name+itemName (KitAgentItem entry) = entry ^. #name++itemKind :: KitItem -> Text+itemKind KitSkillItem {} = "skill"+itemKind KitAgentItem {} = "agent"++kitItemKind :: KitItem -> KitItemKind+kitItemKind KitSkillItem {} = SkillKind+kitItemKind KitAgentItem {} = AgentKind++kindLabel :: KitItemKind -> Text+kindLabel SkillKind = "skill"+kindLabel AgentKind = "agent"++itemVersion :: KitItem -> Maybe Text+itemVersion (KitSkillItem entry) = entry ^. #version+itemVersion (KitAgentItem entry) = entry ^. #version
+ src/Baikai/Kit/Path.hs view
@@ -0,0 +1,40 @@+module Baikai.Kit.Path+  ( safeItemName,+    safeRelativePath,+    safeUnder,+  )+where++import Baikai.Prelude+import Data.Text qualified as Text+import System.FilePath (isAbsolute, normalise, splitDirectories, (</>))++safeRelativePath :: Text -> Either Text FilePath+safeRelativePath input+  | Text.null input = Left "path is empty"+  | Text.any (== '\0') input = Left "path contains a NUL byte"+  | Text.any (== '\\') input = Left "path contains a backslash"+  | isAbsolute raw = Left "path is absolute"+  | ".." `elem` components = Left "path contains a parent-directory component"+  | otherwise = Right normalised+  where+    raw = Text.unpack input+    normalised = normalise raw+    components = splitDirectories normalised++safeItemName :: Text -> Either Text FilePath+safeItemName input+  | Text.any (== '/') input = Left "name must be a single path segment"+  | otherwise = do+      name <- safeRelativePath input+      validateName name+  where+    validateName name+      | name == "." = Left "name cannot be '.'"+      | name == ".." = Left "name cannot be '..'"+      | "." `Text.isPrefixOf` Text.pack name = Left "name cannot start with '.'"+      | length (splitDirectories name) /= 1 = Left "name must be a single path segment"+      | otherwise = Right name++safeUnder :: FilePath -> Text -> Either Text FilePath+safeUnder base rel = (base </>) <$> safeRelativePath rel
+ src/Baikai/Kit/Repo.hs view
@@ -0,0 +1,66 @@+module Baikai.Kit.Repo+  ( ensureKitRepo,+    PullResult (..),+    pullKitRepo,+  )+where++import Baikai.Kit.Config (KitConfig, kitCacheDir)+import Baikai.Prelude+import Control.Exception (IOException, try)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)+import System.Exit (ExitCode (..), exitFailure)+import System.FilePath ((</>))+import System.IO (hPutStrLn, stderr)+import System.Process (readProcessWithExitCode)++data PullResult+  = PullSucceeded+  | PullFailed !Text+  deriving stock (Eq, Show)++ensureKitRepo :: KitConfig -> IO FilePath+ensureKitRepo config = do+  cacheDir <- kitCacheDir config+  exists <- doesDirectoryExist (cacheDir </> ".git")+  if exists+    then do+      result <- pullKitRepo config cacheDir+      case result of+        PullSucceeded -> pure ()+        PullFailed err ->+          hPutStrLn stderr $ "Warning: git pull failed, using cached data. " <> Text.unpack err+      pure cacheDir+    else do+      Text.IO.putStrLn $ "Fetching " <> (config ^. #toolName) <> "-kit..."+      createDirectoryIfMissing True cacheDir+      (exitCode, _, errOut) <-+        readProcessWithExitCode+          "git"+          ["clone", "--depth", "1", Text.unpack (config ^. #repoUrl), cacheDir]+          ""+      case exitCode of+        ExitSuccess -> pure cacheDir+        ExitFailure _ -> do+          manifestExists <- doesFileExist (cacheDir </> "kit.json")+          if manifestExists+            then do+              hPutStrLn stderr $ "Warning: git clone failed, using cached data. " <> errOut+              pure cacheDir+            else do+              hPutStrLn stderr $ "Error: Failed to fetch kit repository: " <> errOut+              exitFailure++pullKitRepo :: KitConfig -> FilePath -> IO PullResult+pullKitRepo _config cacheDir = do+  result <-+    try @IOException $+      readProcessWithExitCode "git" ["-C", cacheDir, "pull", "--ff-only", "--quiet"] ""+  case result of+    Right (ExitSuccess, _, _) -> pure PullSucceeded+    Right (ExitFailure _, _, errOut) ->+      pure (PullFailed (Text.pack errOut))+    Left e ->+      pure (PullFailed (Text.pack (show e)))
+ src/Baikai/Kit/Session.hs view
@@ -0,0 +1,14 @@+module Baikai.Kit.Session+  ( agentDirsForSession,+  )+where++import Baikai.Kit.Config (KitConfig, projectAgentsDir, userAgentsDir)+import Control.Monad (filterM)+import System.Directory (doesDirectoryExist)++agentDirsForSession :: KitConfig -> IO [FilePath]+agentDirsForSession config = do+  userDir <- userAgentsDir config+  projectDir <- projectAgentsDir config+  filterM doesDirectoryExist [userDir, projectDir]
+ src/Baikai/Kit/Sidecar.hs view
@@ -0,0 +1,97 @@+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+      }
+ src/Baikai/Kit/Status.hs view
@@ -0,0 +1,225 @@+module Baikai.Kit.Status+  ( KitState (..),+    StatusRow (..),+    classify,+    collectStatus,+    kitStatus,+    renderState,+  )+where++import Baikai.AgentAssets (AgentAssetProvider, agentTargetPath, skillTargetPath)+import Baikai.Interactive (InteractiveScope (InteractiveProjectScope))+import Baikai.Kit.Config (KitConfig, KitScope (..), providerAgentsBase, providerLabel, sidecarFileName)+import Baikai.Kit.Install (loadManifestMaybe, lookupItem)+import Baikai.Kit.Manifest (AgentEntry, KitItem (..), KitItemKind (..), agentSources, itemKind, itemVersion, kindLabel)+import Baikai.Kit.Repo (ensureKitRepo)+import Baikai.Kit.Sidecar (SidecarMeta, computeKitHash, readSidecar, sidecarPath)+import Baikai.Prelude+import Control.Exception (IOException, try)+import Control.Monad (forM)+import Data.List (groupBy, isPrefixOf, isSuffixOf, nub, sort, sortOn)+import Data.Maybe (fromMaybe)+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO+import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)+import System.FilePath (takeDirectory, (</>))++data KitState+  = KitUpToDate+  | KitOutdated+  | KitDirty+  | KitDirtyOutdated+  | KitDelisted+  | KitUnknown+  deriving stock (Eq, Ord, Show)++data StatusRow = StatusRow+  { name :: !Text,+    kind :: !Text,+    scope :: !Text,+    providers :: !Text,+    installedVersion :: !(Maybe Text),+    latestVersion :: !(Maybe Text),+    state :: !KitState+  }+  deriving stock (Eq, Generic, Show)++renderState :: KitState -> Text+renderState = \case+  KitUpToDate -> "up-to-date"+  KitOutdated -> "outdated"+  KitDirty -> "dirty"+  KitDirtyOutdated -> "dirty+outdated"+  KitDelisted -> "delisted"+  KitUnknown -> "unknown"++classify :: Maybe SidecarMeta -> Maybe KitItem -> Maybe Text -> KitState+classify Nothing _ _ = KitUnknown+classify (Just _) Nothing _ = KitDelisted+classify (Just sm) (Just it) mUpstreamHash =+  let outdated = case itemVersion it of+        Just latest -> sm ^. #version /= Just latest+        Nothing -> False+      dirty = case mUpstreamHash of+        Just up -> up /= sm ^. #hash+        Nothing -> False+   in case (outdated, dirty) of+        (True, True) -> KitDirtyOutdated+        (True, False) -> KitOutdated+        (False, True) -> KitDirty+        (False, False) -> KitUpToDate++kitStatus :: KitConfig -> IO ()+kitStatus config = do+  cacheDir <- resolveCacheOrEmpty config+  rows <- collectStatus config cacheDir [(UserScope, "user"), (ProjectScope, "project")]+  renderStatusTable rows++collectStatus :: KitConfig -> FilePath -> [(KitScope, Text)] -> IO [StatusRow]+collectStatus config cacheDir scopes = do+  mManifest <- loadManifestMaybe cacheDir+  fmap concat . forM scopes $ \(scope, scopeText) -> do+    items <- scanInstalled config scope+    forM items $ \(provider, baseDir, itemName', scannedKind) -> do+      let mItem = lookupItem itemName' =<< mManifest+      mSidecar <- readSidecar (sidecarPath provider scannedKind itemName' baseDir (sidecarFileName config))+      mUpstreamHash <- upstreamHash cacheDir mItem+      let state' = classify mSidecar mItem mUpstreamHash+      pure+        StatusRow+          { name = itemName',+            kind = maybe (kindLabel scannedKind) itemKind mItem,+            scope = scopeText,+            providers = providerLabel provider,+            installedVersion = mSidecar >>= (^. #version),+            latestVersion = mItem >>= itemVersion,+            state = state'+          }++resolveCacheOrEmpty :: KitConfig -> IO FilePath+resolveCacheOrEmpty config = do+  result <- try @IOException (ensureKitRepo config)+  case result of+    Right dir -> do+      manifestExists <- doesFileExist (dir </> "kit.json")+      pure (if manifestExists then dir else "")+    Left _ -> pure ""++upstreamHash :: FilePath -> Maybe KitItem -> IO (Maybe Text)+upstreamHash "" _ = pure Nothing+upstreamHash _ Nothing = pure Nothing+upstreamHash cacheDir (Just (KitSkillItem entry)) =+  tryHash (cacheDir </> Text.unpack (entry ^. #path)) (entry ^. #files)+upstreamHash cacheDir (Just (KitAgentItem entry)) =+  tryHash (agentSourceBase cacheDir entry) (map (Text.pack . snd) (agentSources entry))++tryHash :: FilePath -> [Text] -> IO (Maybe Text)+tryHash base files = do+  result <- try @IOException (computeKitHash base files)+  case result of+    Right h -> pure (Just h)+    Left _ -> pure Nothing++scanInstalled :: KitConfig -> KitScope -> IO [(AgentAssetProvider, FilePath, Text, KitItemKind)]+scanInstalled config scope = fmap concat $+  forM (config ^. #providers) $ \provider -> do+    baseDir <- providerAgentsBase config provider scope+    skillItems <- scanSkills provider (takeDirectory (baseDir </> skillTargetPath provider InteractiveProjectScope "__scan__"))+    agentItems <- scanAgents provider (takeDirectory (baseDir </> agentTargetPath provider InteractiveProjectScope "__scan__"))+    pure [(provider', baseDir, itemName', kind) | (provider', itemName', kind) <- skillItems ++ agentItems]++scanSkills :: AgentAssetProvider -> FilePath -> IO [(AgentAssetProvider, Text, KitItemKind)]+scanSkills provider dir = do+  exists <- doesDirectoryExist dir+  if exists+    then do+      entries <- listDirectory dir+      pure [(provider, Text.pack e, SkillKind) | e <- entries, visible e]+    else pure []++scanAgents :: AgentAssetProvider -> FilePath -> IO [(AgentAssetProvider, Text, KitItemKind)]+scanAgents provider dir = do+  exists <- doesDirectoryExist dir+  if exists+    then do+      entries <- listDirectory dir+      let files = filter (\f -> agentExtension provider `isSuffixOf` f && visible f) entries+      pure [(provider, Text.pack (dropAgentExtension provider f), AgentKind) | f <- files]+    else pure []++renderStatusTable :: [StatusRow] -> IO ()+renderStatusTable [] = Text.IO.putStrLn "No kit items installed."+renderStatusTable rows = do+  let displayRows = aggregateStatusRows rows+      nameW = colWidth displayRows "NAME" (^. #name)+      kindW = colWidth displayRows "TYPE" (^. #kind)+      scopeW = colWidth displayRows "SCOPE" (^. #scope)+      providersW = colWidth displayRows "PROVIDERS" (^. #providers)+      instW = colWidth displayRows "INSTALLED" (renderMVer . view #installedVersion)+      latW = colWidth displayRows "LATEST" (renderMVer . view #latestVersion)+      hdr =+        Text.justifyLeft (nameW + 2) ' ' "NAME"+          <> Text.justifyLeft (kindW + 2) ' ' "TYPE"+          <> Text.justifyLeft (scopeW + 2) ' ' "SCOPE"+          <> Text.justifyLeft (providersW + 2) ' ' "PROVIDERS"+          <> Text.justifyLeft (instW + 2) ' ' "INSTALLED"+          <> Text.justifyLeft (latW + 2) ' ' "LATEST"+          <> "STATE"+  Text.IO.putStrLn hdr+  mapM_ (printRow nameW kindW scopeW providersW instW latW) displayRows+  where+    colWidth displayRows colTitle f =+      maximum (Text.length colTitle : map (Text.length . f) displayRows)++    renderMVer = fromMaybe "-"++    printRow nameW kindW scopeW providersW instW latW row =+      Text.IO.putStrLn $+        Text.justifyLeft (nameW + 2) ' ' (row ^. #name)+          <> Text.justifyLeft (kindW + 2) ' ' (row ^. #kind)+          <> Text.justifyLeft (scopeW + 2) ' ' (row ^. #scope)+          <> Text.justifyLeft (providersW + 2) ' ' (row ^. #providers)+          <> Text.justifyLeft (instW + 2) ' ' (renderMVer (row ^. #installedVersion))+          <> Text.justifyLeft (latW + 2) ' ' (renderMVer (row ^. #latestVersion))+          <> renderState (row ^. #state)++aggregateStatusRows :: [StatusRow] -> [StatusRow]+aggregateStatusRows rows =+  map summarize grouped+  where+    grouped = groupBy sameKey $ sortOn rowKey rows+    rowKey row =+      ( row ^. #name,+        row ^. #kind,+        row ^. #scope,+        row ^. #installedVersion,+        row ^. #latestVersion,+        row ^. #state+      )+    sameKey a b = rowKey a == rowKey b+    summarize groupRows@(firstRow : _) =+      firstRow & #providers .~ Text.intercalate "," (sort (nub (map (^. #providers) groupRows)))+    summarize [] = error "aggregateStatusRows: empty group"++agentSourceBase :: FilePath -> AgentEntry -> FilePath+agentSourceBase repoDir entry =+  case entry ^. #files of+    Just _ -> repoDir </> Text.unpack (entry ^. #path)+    Nothing -> repoDir </> takeDirectory (Text.unpack (entry ^. #path))++agentExtension :: AgentAssetProvider -> String+agentExtension provider =+  case agentTargetPath provider InteractiveProjectScope "__scan__" of+    path+      | ".toml" `isSuffixOf` path -> ".toml"+      | ".md" `isSuffixOf` path -> ".md"+      | otherwise -> ""++dropAgentExtension :: AgentAssetProvider -> FilePath -> FilePath+dropAgentExtension provider file =+  let ext = agentExtension provider+   in take (length file - length ext) file++visible :: FilePath -> Bool+visible = not . ("." `isPrefixOf`)
+ test/Main.hs view
@@ -0,0 +1,498 @@+{-# LANGUAGE OverloadedStrings #-}++module Main (main) where++import Baikai.Interactive (InteractiveProvider (InteractiveClaude, InteractiveCodex))+import Baikai.Kit+  ( AgentEntry (..),+    KitConfig (..),+    KitItem (..),+    KitItemKind (..),+    KitManifest (..),+    KitScope (UserScope),+    KitState (..),+    PullResult (..),+    RemovalOutcome (..),+    SidecarMeta (..),+    SkillEntry (..),+    classify,+    collectStatus,+    computeKitHash,+    installItem,+    pullKitRepo,+    readSidecar,+    renderUninstallReport,+    safeItemName,+    safeRelativePath,+    safeUnder,+    sidecarFileName,+    sidecarPath,+    stripYamlFrontmatter,+    uninstallItem,+    uninstallOutcomes,+    updateKit,+  )+import Baikai.Prelude+import Control.Exception (finally, try)+import Data.Aeson qualified as Aeson+import Data.ByteString qualified as BS+import Data.List (find, isSuffixOf)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import System.Directory+  ( createDirectoryIfMissing,+    doesDirectoryExist,+    doesFileExist,+    doesPathExist,+    listDirectory,+    removeDirectoryRecursive,+  )+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, (</>))+import System.IO.Temp (withSystemTempDirectory)+import Test.Tasty (TestTree, defaultMain, localOption, testGroup)+import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))+import Test.Tasty.Runners (NumThreads (NumThreads))++main :: IO ()+main =+  defaultMain $+    localOption (NumThreads 1) $+      testGroup+        "baikai-kit"+        [ manifestTests,+          hashTests,+          pathSafetyTests,+          frontmatterTests,+          classifyTests,+          statusFilesystemTests,+          installRoundTripTests+        ]++manifestTests :: TestTree+manifestTests =+  testGroup+    "Manifest backward compatibility"+    [ fixtureCase "mori-kit.json" 2 4 0,+      fixtureCase "rei-kit.json" 1 9 1,+      fixtureCase "seihou-kit.json" 1 2 0+    ]++fixtureCase :: FilePath -> Int -> Int -> Int -> TestTree+fixtureCase file expectedVersion expectedSkills expectedAgents =+  testCase (file <> " decodes") $ do+    manifest <- decodeFixture file+    (manifest ^. #version) @?= expectedVersion+    length (manifest ^. #skills) @?= expectedSkills+    length (manifest ^. #agents) @?= expectedAgents++hashTests :: TestTree+hashTests =+  testGroup+    "Hash"+    [ testCase "computeKitHash is deterministic regardless of input order" $+        withSystemTempDirectory "baikai-kit-hash" $ \dir -> do+          BS.writeFile (dir </> "a.md") "alpha"+          BS.writeFile (dir </> "b.md") "beta"+          BS.writeFile (dir </> "c.md") "gamma"+          h1 <- computeKitHash dir ["a.md", "b.md", "c.md"]+          h2 <- computeKitHash dir ["c.md", "a.md", "b.md"]+          h1 @?= h2+          assertBool "hash should carry sha256 prefix" ("sha256:" `Text.isPrefixOf` h1),+      testCase "computeKitHash changes when file content changes" $+        withSystemTempDirectory "baikai-kit-hash-mut" $ \dir -> do+          BS.writeFile (dir </> "a.md") "alpha"+          before <- computeKitHash dir ["a.md"]+          BS.writeFile (dir </> "a.md") "alpha-modified"+          after <- computeKitHash dir ["a.md"]+          assertBool "hashes must differ after content change" (before /= after)+    ]++classifyTests :: TestTree+classifyTests =+  testGroup+    "Status.classify"+    [ testCase "no sidecar => unknown" $+        classify Nothing (Just (mkSkillItem "foo" (Just "1.0"))) (Just "h") @?= KitUnknown,+      testCase "no upstream entry with a sidecar => delisted" $+        classify (Just (mkSidecar (Just "1.0") "h")) Nothing (Just "h") @?= KitDelisted,+      testCase "version mismatch => outdated" $+        classify+          (Just (mkSidecar (Just "1.0") "h"))+          (Just (mkSkillItem "foo" (Just "2.0")))+          (Just "h")+          @?= KitOutdated,+      testCase "version and hash mismatch => dirty+outdated" $+        classify+          (Just (mkSidecar (Just "1.0") "h1"))+          (Just (mkSkillItem "foo" (Just "2.0")))+          (Just "h2")+          @?= KitDirtyOutdated,+      testCase "hash mismatch => dirty" $+        classify+          (Just (mkSidecar (Just "1.0") "h1"))+          (Just (mkSkillItem "foo" (Just "1.0")))+          (Just "h2")+          @?= KitDirty,+      testCase "version and hash match => up-to-date" $+        classify+          (Just (mkSidecar (Just "1.0") "h"))+          (Just (mkSkillItem "foo" (Just "1.0")))+          (Just "h")+          @?= KitUpToDate,+      testCase "no upstream hash on matching version => up-to-date" $+        classify+          (Just (mkSidecar (Just "1.0") "h"))+          (Just (mkAgentItem "foo" (Just "1.0")))+          Nothing+          @?= KitUpToDate+    ]++pathSafetyTests :: TestTree+pathSafetyTests =+  testGroup+    "Path safety"+    [ testCase "safeRelativePath accepts and normalises harmless paths" $ do+        safeRelativePath "SKILL.md" @?= Right "SKILL.md"+        safeRelativePath "skills/review" @?= Right "skills/review"+        safeRelativePath "a/./b" @?= Right ("a" </> "b"),+      testCase "safeRelativePath rejects zip-slip, absolute paths, backslashes, and NUL" $ do+        mapM_+          (assertLeft . safeRelativePath)+          ["", "/etc/passwd", "../x", "a/../../x", "..", "a/..", "a\\..\\b", "a\0b"],+      testCase "safeItemName rejects multi-component and hidden names" $ do+        safeItemName "reviewer" @?= Right "reviewer"+        mapM_ (assertLeft . safeItemName) ["a/b", ".", ".hidden"],+      testCase "safeUnder rejects an absolute right operand" $+        assertLeft (safeUnder "/base" "/etc/passwd"),+      testCase "install refuses a manifest file path that escapes the install root" $+        withPreparedKitHome $ \home cache -> do+          BS.writeFile (cache </> "kit.json") maliciousManifestJson+          result <- try @ExitCode (installItem testConfig "evil" UserScope)+          result @?= Left (ExitFailure 1)+          assertFileMissing (takeDirectory home </> "escape.txt"),+      testCase "uninstall refuses a traversal name" $+        withPreparedKitHome $ \home _cache -> do+          let victim = home </> "victim"+          createDirectoryIfMissing True victim+          result <- try @ExitCode (uninstallItem testConfig "../victim" UserScope)+          result @?= Left (ExitFailure 1)+          assertDirectoryExists victim+    ]++frontmatterTests :: TestTree+frontmatterTests =+  testGroup+    "Frontmatter"+    [ testCase "stripYamlFrontmatter handles LF, CRLF, and final delimiter without newline" $ do+        stripYamlFrontmatter "---\nname: x\n---\nBody.\n" @?= "Body.\n"+        stripYamlFrontmatter "---\r\nname: x\r\n---\r\nBody.\r\n" @?= "Body.\n"+        stripYamlFrontmatter "---\nname: x\n---" @?= "",+      testCase "stripYamlFrontmatter leaves non-frontmatter and unterminated blocks unchanged" $ do+        stripYamlFrontmatter "Body.\n" @?= "Body.\n"+        stripYamlFrontmatter "---\nname: x\nBody.\n" @?= "---\nname: x\nBody.\n"+    ]++statusFilesystemTests :: TestTree+statusFilesystemTests =+  testGroup+    "Status filesystem"+    [ testCase "sidecarPath drops any agent target extension" $+        withPreparedKitHome $ \home _cache -> do+          let claudeBase = home </> ".config" </> "testkit" </> "agents"+              codexBase = home+              sidecar = sidecarFileName testConfig+          sidecarPath InteractiveClaude AgentKind "reviewer" claudeBase sidecar+            @?= claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"+          sidecarPath InteractiveCodex AgentKind "reviewer" codexBase sidecar+            @?= codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json",+      testCase "delisted installed item keeps sidecar version in status" $+        withPreparedKitHome $ \_home cache -> do+          installItem testConfig "demo" UserScope+          BS.writeFile (cache </> "kit.json") manifestWithoutDemoJson+          rows <- collectStatus testConfig cache [(UserScope, "user")]+          let demoRows = filter ((== "demo") . view #name) rows+          assertBool "expected demo status rows" (not (null demoRows))+          mapM_ (\row -> row ^. #state @?= KitDelisted) demoRows+          mapM_ (\row -> row ^. #installedVersion @?= Just "0.1.0") demoRows,+      testCase "version and cached hash drift reports dirty+outdated" $+        withPreparedKitHome $ \_home cache -> do+          installItem testConfig "demo" UserScope+          BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "changed instructions\n"+          BS.writeFile (cache </> "kit.json") manifestWithDemoVersionJson+          rows <- collectStatus testConfig cache [(UserScope, "user")]+          let demoRows = filter ((== "demo") . view #name) rows+          assertBool "expected demo status rows" (not (null demoRows))+          mapM_ (\row -> row ^. #state @?= KitDirtyOutdated) demoRows+    ]++installRoundTripTests :: TestTree+installRoundTripTests =+  testGroup+    "Install"+    [ testCase "skill and agent round-trip through Claude and Codex layouts with sidecars" $+        withPreparedKitHome $ \home _cache -> do+          let config = testConfig+              claudeBase = home </> ".config" </> "testkit" </> "agents"+              codexBase = home+              claudeSkill = claudeBase </> ".claude" </> "skills" </> "demo"+              codexSkill = codexBase </> ".agents" </> "skills" </> "demo"+              claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"+              codexAgent = codexBase </> ".codex" </> "agents" </> "reviewer.toml"+              claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"+              codexAgentSidecar = codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json"+          installItem config "demo" UserScope+          assertFileExists (claudeSkill </> "SKILL.md")+          assertFileExists (codexSkill </> "SKILL.md")+          assertFileExists (claudeSkill </> ".testkit-kit.json")+          meta <- readSidecar (claudeSkill </> ".testkit-kit.json")+          case meta of+            Just sidecar -> do+              (sidecar ^. #name) @?= ("demo" :: Text)+              (sidecar ^. #kind) @?= ("skill" :: Text)+            Nothing -> assertFailure "expected a skill sidecar"+          uninstallItem config "demo" UserScope+          assertDirectoryMissing claudeSkill+          assertDirectoryMissing codexSkill+          installItem config "reviewer" UserScope+          assertFileExists claudeAgent+          assertFileExists codexAgent+          assertFileExists claudeAgentSidecar+          assertFileExists codexAgentSidecar+          toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent+          assertBool "Codex agent TOML should contain developer instructions" ("developer_instructions" `Text.isInfixOf` toml)+          uninstallItem config "reviewer" UserScope+          assertFileMissing claudeAgent+          assertFileMissing codexAgent+          assertFileMissing claudeAgentSidecar+          assertFileMissing codexAgentSidecar,+      testCase "Codex TOML strips CRLF YAML frontmatter" $+        withPreparedKitHome $ \home cache -> do+          let codexAgent = home </> ".codex" </> "agents" </> "reviewer.toml"+          BS.writeFile (cache </> "agents" </> "reviewer.md") "---\r\nname: reviewer\r\n---\r\nReview carefully.\r\n"+          installItem testConfig "reviewer" UserScope+          toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent+          assertBool "frontmatter name should be stripped" (not ("name: reviewer" `Text.isInfixOf` toml))+          assertBool "CR characters should be stripped" (not ("\r" `Text.isInfixOf` toml)),+      testCase "failed provider write rolls back all staged writes" $+        withPreparedKitHome $ \home _cache -> do+          let claudeBase = home </> ".config" </> "testkit" </> "agents"+              claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"+              claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"+          BS.writeFile (home </> ".codex") ""+          result <- try @ExitCode (installItem testConfig "reviewer" UserScope)+          result @?= Left (ExitFailure 1)+          assertFileMissing claudeAgent+          assertFileMissing claudeAgentSidecar+          tmpFiles <- findFilesWithSuffix home ".baikai-kit-tmp"+          tmpFiles @?= [],+      testCase "renderUninstallReport names actual assets and stale metadata" $ do+        renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False]+          @?= "Uninstalled skill 'demo' from user scope (claude)."+        renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False, RemovalOutcome InteractiveCodex True False False]+          @?= "Uninstalled skill 'demo' from user scope (claude,codex)."+        renderUninstallReport "reviewer" UserScope [RemovalOutcome InteractiveClaude False False True]+          @?= "Removed stale kit metadata for 'reviewer' from user scope."+        renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude False False False]+          @?= "'demo' is not installed in user scope.",+      testCase "uninstallOutcomes reports per-provider removals" $+        withPreparedKitHome $ \home _cache -> do+          let codexSkill = home </> ".agents" </> "skills" </> "demo"+          installItem testConfig "demo" UserScope+          removeDirectoryRecursive codexSkill+          outcomes <- uninstallOutcomes testConfig "demo" UserScope+          let claudeOutcome = findOutcome InteractiveClaude outcomes+              codexOutcome = findOutcome InteractiveCodex outcomes+          view #skillRemoved claudeOutcome @?= True+          view #skillRemoved codexOutcome @?= False+          view #agentRemoved codexOutcome @?= False+          view #sidecarRemoved codexOutcome @?= False,+      testCase "pull failure is returned and update exits nonzero" $+        withPreparedKitHome $ \_home cache -> do+          result <- pullKitRepo testConfig cache+          case result of+            PullFailed _ -> pure ()+            PullSucceeded -> assertFailure "expected fake .git cache pull to fail"+          updateResult <- try @ExitCode (updateKit testConfig Nothing)+          updateResult @?= Left (ExitFailure 1)+    ]++decodeFixture :: FilePath -> IO KitManifest+decodeFixture file = do+  bytes <- BS.readFile ("test/fixtures" </> file)+  case Aeson.eitherDecodeStrict' bytes of+    Right manifest -> pure manifest+    Left err -> assertFailure ("failed to decode " <> file <> ": " <> err)++mkSidecar :: Maybe Text -> Text -> SidecarMeta+mkSidecar mVersion h =+  SidecarMeta+    { name = "foo",+      kind = "skill",+      version = mVersion,+      hash = h,+      installedAt = "2026-05-13T00:00:00Z"+    }++mkSkillItem :: Text -> Maybe Text -> KitItem+mkSkillItem n mVersion =+  KitSkillItem+    SkillEntry+      { name = n,+        description = "x",+        version = mVersion,+        path = "skills/foo",+        files = ["SKILL.md"]+      }++mkAgentItem :: Text -> Maybe Text -> KitItem+mkAgentItem n mVersion =+  KitAgentItem+    AgentEntry+      { name = n,+        description = "x",+        version = mVersion,+        path = "agents/foo.md",+        files = Nothing+      }++testConfig :: KitConfig+testConfig =+  KitConfig+    { toolName = "testkit",+      repoUrl = "file:///not-used",+      providers = [InteractiveClaude, InteractiveCodex]+    }++withPreparedKitHome :: (FilePath -> FilePath -> IO a) -> IO a+withPreparedKitHome action =+  withSystemTempDirectory "baikai-kit-home" $ \tmp -> do+    oldHome <- lookupEnv "HOME"+    let home = tmp </> "home"+        cache = home </> ".cache" </> "testkit" </> "kit"+    createDirectoryIfMissing True (cache </> ".git")+    createDirectoryIfMissing True (cache </> "skills" </> "demo")+    createDirectoryIfMissing True (cache </> "agents")+    BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "skill instructions\n"+    BS.writeFile (cache </> "agents" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"+    BS.writeFile (cache </> "kit.json") manifestJson+    setEnv "HOME" home+    action home cache `finally` restoreHome oldHome+  where+    restoreHome Nothing = unsetEnv "HOME"+    restoreHome (Just value) = setEnv "HOME" value++manifestJson :: BS.ByteString+manifestJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[{",+        "\"name\":\"demo\",",+        "\"description\":\"Demo skill\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"skills/demo\",",+        "\"files\":[\"SKILL.md\"]",+        "}],",+        "\"agents\":[{",+        "\"name\":\"reviewer\",",+        "\"description\":\"Review agent\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"agents/reviewer.md\"",+        "}]} "+      ]++manifestWithoutDemoJson :: BS.ByteString+manifestWithoutDemoJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[],",+        "\"agents\":[{",+        "\"name\":\"reviewer\",",+        "\"description\":\"Review agent\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"agents/reviewer.md\"",+        "}]} "+      ]++manifestWithDemoVersionJson :: BS.ByteString+manifestWithDemoVersionJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[{",+        "\"name\":\"demo\",",+        "\"description\":\"Demo skill\",",+        "\"version\":\"0.2.0\",",+        "\"path\":\"skills/demo\",",+        "\"files\":[\"SKILL.md\"]",+        "}],",+        "\"agents\":[{",+        "\"name\":\"reviewer\",",+        "\"description\":\"Review agent\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"agents/reviewer.md\"",+        "}]} "+      ]++maliciousManifestJson :: BS.ByteString+maliciousManifestJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[{",+        "\"name\":\"evil\",",+        "\"description\":\"Evil skill\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"skills/demo\",",+        "\"files\":[\"../../../../escape.txt\"]",+        "}],",+        "\"agents\":[]} "+      ]++assertLeft :: (Show b) => Either a b -> IO ()+assertLeft result =+  case result of+    Left _ -> pure ()+    Right value -> assertFailure ("expected Left, got Right " <> show value)++assertFileExists :: FilePath -> IO ()+assertFileExists path = do+  exists <- doesFileExist path+  assertBool ("expected file to exist: " <> path) exists++assertFileMissing :: FilePath -> IO ()+assertFileMissing path = do+  exists <- doesFileExist path+  assertBool ("expected file to be missing: " <> path) (not exists)++assertDirectoryMissing :: FilePath -> IO ()+assertDirectoryMissing path = do+  exists <- doesDirectoryExist path+  assertBool ("expected directory to be missing: " <> path) (not exists)++assertDirectoryExists :: FilePath -> IO ()+assertDirectoryExists path = do+  exists <- doesDirectoryExist path+  assertBool ("expected directory to exist: " <> path) exists++findFilesWithSuffix :: FilePath -> String -> IO [FilePath]+findFilesWithSuffix root suffix = do+  exists <- doesPathExist root+  if not exists+    then pure []+    else do+      isDir <- doesDirectoryExist root+      if not isDir+        then pure [root | suffix `isSuffixOf` root]+        else do+          names <- listDirectory root+          fmap concat $ mapM (\name -> findFilesWithSuffix (root </> name) suffix) names++findOutcome :: InteractiveProvider -> [RemovalOutcome] -> RemovalOutcome+findOutcome expected outcomes =+  case find ((== expected) . view #provider) outcomes of+    Just outcome -> outcome+    Nothing -> error "expected provider outcome"