baikai-kit 0.2.0.1 → 0.3.0.0
raw patch · 10 files changed
+1162/−177 lines, 10 filesdep ~optparse-applicativedep ~processPVP ok
version bump matches the API change (PVP)
Dependency ranges changed: optparse-applicative, process
API changes (from Hackage documentation)
- Baikai.Kit.Status: KitDirty :: KitState
- Baikai.Kit.Status: KitDirtyOutdated :: KitState
- Baikai.Kit.Status: KitUpToDate :: KitState
- Baikai.Kit.Status: [state] :: StatusRow -> !KitState
- Baikai.Kit.Status: data KitState
- Baikai.Kit.Status: instance GHC.Classes.Eq Baikai.Kit.Status.KitState
- Baikai.Kit.Status: instance GHC.Classes.Ord Baikai.Kit.Status.KitState
- Baikai.Kit.Status: instance GHC.Internal.Show.Show Baikai.Kit.Status.KitState
- Baikai.Kit.Status: renderState :: KitState -> Text
+ Baikai.Kit.Command: HumanOutput :: OutputFormat
+ Baikai.Kit.Command: JsonOutput :: OutputFormat
+ Baikai.Kit.Command: data OutputFormat
+ Baikai.Kit.Command: instance GHC.Classes.Eq Baikai.Kit.Command.KitCommand
+ Baikai.Kit.Command: instance GHC.Classes.Eq Baikai.Kit.Command.OutputFormat
+ Baikai.Kit.Command: instance GHC.Internal.Show.Show Baikai.Kit.Command.OutputFormat
+ Baikai.Kit.Config: [chooseItem] :: KitConfig -> !Maybe (KitManifest -> IO (Maybe Text))
+ Baikai.Kit.Config: [projectRoot] :: KitConfig -> !IO FilePath
+ Baikai.Kit.Config: findProjectRoot :: [FilePath] -> FilePath -> IO (Maybe FilePath)
+ Baikai.Kit.Config: kitConfig :: Text -> Text -> [AgentAssetProvider] -> KitConfig
+ Baikai.Kit.Config: projectRootByMarkers :: [FilePath] -> IO FilePath
+ Baikai.Kit.Error: KitItemNameRequired :: KitError
+ Baikai.Kit.Install: Edited :: LocalEdits
+ Baikai.Kit.Install: EditsUnknown :: LocalEdits
+ Baikai.Kit.Install: Unedited :: LocalEdits
+ Baikai.Kit.Install: checkLocalEdits :: KitConfig -> AgentAssetProvider -> KitScope -> KitItemKind -> Text -> IO (Either KitError LocalEdits)
+ Baikai.Kit.Install: data LocalEdits
+ Baikai.Kit.Install: instance GHC.Classes.Eq Baikai.Kit.Install.LocalEdits
+ Baikai.Kit.Install: instance GHC.Internal.Show.Show Baikai.Kit.Install.LocalEdits
+ Baikai.Kit.Json: kitJsonFormatVersion :: Int
+ Baikai.Kit.Json: listDocument :: UpstreamAvailability -> KitManifest -> [InstalledCopy] -> Value
+ Baikai.Kit.Json: statusDocument :: StatusReport -> Value
+ Baikai.Kit.Json: updateDocument :: UpdateReport -> Value
+ Baikai.Kit.Status: InstalledCopy :: !Text -> !KitItemKind -> !KitScope -> !AgentAssetProvider -> !FilePath -> !Maybe Text -> InstalledCopy
+ Baikai.Kit.Status: KitChangedUpstream :: KitCondition
+ Baikai.Kit.Status: KitLocalEditsUnknown :: KitCondition
+ Baikai.Kit.Status: KitLocallyModified :: KitCondition
+ Baikai.Kit.Status: [conditions] :: StatusRow -> ![KitCondition]
+ Baikai.Kit.Status: [path] :: InstalledCopy -> !FilePath
+ Baikai.Kit.Status: [provider] :: InstalledCopy -> !AgentAssetProvider
+ Baikai.Kit.Status: [version] :: InstalledCopy -> !Maybe Text
+ Baikai.Kit.Status: conditionLabel :: KitCondition -> Text
+ Baikai.Kit.Status: data InstalledCopy
+ Baikai.Kit.Status: data KitCondition
+ Baikai.Kit.Status: installedCopies :: KitConfig -> IO [InstalledCopy]
+ Baikai.Kit.Status: instance GHC.Classes.Eq Baikai.Kit.Status.InstalledCopy
+ Baikai.Kit.Status: instance GHC.Classes.Eq Baikai.Kit.Status.KitCondition
+ Baikai.Kit.Status: instance GHC.Classes.Ord Baikai.Kit.Status.KitCondition
+ Baikai.Kit.Status: instance GHC.Internal.Enum.Bounded Baikai.Kit.Status.KitCondition
+ Baikai.Kit.Status: instance GHC.Internal.Enum.Enum Baikai.Kit.Status.KitCondition
+ Baikai.Kit.Status: instance GHC.Internal.Generics.Generic Baikai.Kit.Status.InstalledCopy
+ Baikai.Kit.Status: instance GHC.Internal.Show.Show Baikai.Kit.Status.InstalledCopy
+ Baikai.Kit.Status: instance GHC.Internal.Show.Show Baikai.Kit.Status.KitCondition
+ Baikai.Kit.Status: renderConditions :: [KitCondition] -> Text
- Baikai.Kit.Command: KitInstall :: !Text -> !KitScope -> KitCommand
+ Baikai.Kit.Command: KitInstall :: !Maybe Text -> !KitScope -> KitCommand
- Baikai.Kit.Command: KitList :: KitCommand
+ Baikai.Kit.Command: KitList :: !OutputFormat -> KitCommand
- Baikai.Kit.Command: KitStatus :: KitCommand
+ Baikai.Kit.Command: KitStatus :: !OutputFormat -> KitCommand
- Baikai.Kit.Command: KitUpdate :: !Maybe Text -> !OverwritePolicy -> KitCommand
+ Baikai.Kit.Command: KitUpdate :: !Maybe Text -> !OverwritePolicy -> !OutputFormat -> KitCommand
- Baikai.Kit.Command: kitCommandParser :: Parser KitCommand
+ Baikai.Kit.Command: kitCommandParser :: KitConfig -> Parser KitCommand
- Baikai.Kit.Config: KitConfig :: !Text -> !Text -> ![AgentAssetProvider] -> KitConfig
+ Baikai.Kit.Config: KitConfig :: !Text -> !Text -> ![AgentAssetProvider] -> !IO FilePath -> !Maybe (KitManifest -> IO (Maybe Text)) -> KitConfig
- Baikai.Kit.Status: KitDelisted :: KitState
+ Baikai.Kit.Status: KitDelisted :: KitCondition
- Baikai.Kit.Status: KitOutdated :: KitState
+ Baikai.Kit.Status: KitOutdated :: KitCondition
- Baikai.Kit.Status: KitUnknown :: KitState
+ Baikai.Kit.Status: KitUnknown :: KitCondition
- Baikai.Kit.Status: KitUpstreamRefused :: KitState
+ Baikai.Kit.Status: KitUpstreamRefused :: KitCondition
- Baikai.Kit.Status: StatusRow :: !Text -> !Text -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> !KitState -> StatusRow
+ Baikai.Kit.Status: StatusRow :: !Text -> !Text -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> ![KitCondition] -> StatusRow
- Baikai.Kit.Status: classify :: Maybe SidecarMeta -> Maybe KitItem -> Maybe Text -> KitState
+ Baikai.Kit.Status: classify :: Maybe SidecarMeta -> Maybe KitItem -> Maybe Text -> [KitCondition]
Files
- CHANGELOG.md +98/−0
- baikai-kit.cabal +59/−46
- src/Baikai/Kit.hs +2/−0
- src/Baikai/Kit/Command.hs +101/−35
- src/Baikai/Kit/Config.hs +81/−7
- src/Baikai/Kit/Error.hs +5/−0
- src/Baikai/Kit/Install.hs +50/−23
- src/Baikai/Kit/Json.hs +142/−0
- src/Baikai/Kit/Status.hs +104/−35
- test/Main.hs +520/−31
CHANGELOG.md view
@@ -7,6 +7,104 @@ ## [Unreleased] +## [baikai 0.7.1.0] - 2026-09-23++### Added++- Curated GPT-6 Sol and Luna on OpenAI Responses and Claude Opus 5.5 on+ Anthropic Messages (`openai_gpt_6_sol`, `openai_gpt_6_luna`,+ `anthropic_claude_opus_5_5`), with endpoint compatibility and standard,+ long-context, cache-duration, and fast-mode prices where applicable. All+ three passed live acceptance on 2026-09-23. Sol and Luna dispatch to+ `OpenAIResponses`, so calling them requires the+ `Baikai.Provider.OpenAI.Responses.register` call that `baikai-openai 0.7.0.0`+ introduced.++## [baikai-kit 0.3.0.0] - 2026-09-23++Closes four improvement requests from the tools that ship `baikai-kit` as their+`kit` command: project scope resolves from a configurable root (IR-8),+`kit status` reports local edits separately from upstream drift (IR-7),+`kit install` without a name asks a tool-supplied chooser (IR-6), and `list`,+`status` and `update` print versioned JSON (IR-9). A consumer raising its bound+builds `KitConfig` with `kitConfig`, passes it to `kitCommandParser`, and+matches on `StatusRow.conditions`; each break is at a call site the compiler+names.++### Added++- `baikai-kit`: `KitConfig.projectRoot :: IO FilePath` says where project scope+ lives. Install, status, update, uninstall and `agentDirsForSession` all derive+ project-scope paths from it, so they agree whichever subdirectory a command+ runs from. `kitConfig` builds a configuration with every optional field at its+ default (project scope is the current directory, as before);+ `projectRootByMarkers [".git", ".mytool"]` is a ready-made resolver that walks+ up to the nearest marker and falls back to the current directory, and+ `findProjectRoot` is the underlying walk. Resolves IR-8.++- `baikai-kit`: `kit status` reports local edits. It runs the same+ installed-file check `kit update` uses to skip an item, and shows+ `modified` for an edited copy and `edits-unknown` for one whose sidecar+ predates the installed-file hash. The check is exported as+ `checkLocalEdits`, returning `LocalEdits` (`Unedited`, `Edited`,+ `EditsUnknown`). Resolves IR-7.++- `baikai-kit`: `kit install` with no name asks a chooser the tool supplies in+ the new `KitConfig.chooseItem :: Maybe (KitManifest -> IO (Maybe Text))`+ field. The engine refreshes the kit, passes the whole manifest, and installs+ what the chooser returns; a cancelled choice prints+ `No item chosen; nothing installed.` and exits 0. With no chooser (the+ `kitConfig` default) the command fails with the new `KitItemNameRequired`+ error, which tells the user to pass `NAME`. The engine ships no picker.+ `KitCommand` derives `Eq`, and `kit install --help` names the tool's+ `.<tool>/agents` directory. Resolves IR-6.++- `baikai-kit`: `kit list`, `kit status` and `kit update` accept `--json` and+ print exactly one versioned JSON document on stdout+ (`{"formatVersion": 1, "document": "kit-list" | "kit-status" | "kit-update", …}`);+ warnings and the first-clone notice go to stderr, and a failed command writes+ nothing to stdout. The shapes are written by explicit encoders —+ `Baikai.Kit.Json.listDocument`, `statusDocument`, `updateDocument`, and+ `kitJsonFormatVersion` — so library callers get the same values, and they are+ pinned by golden tests. `Baikai.Kit.Command.OutputFormat` selects the mode,+ and `Baikai.Kit.Status.InstalledCopy` / `installedCopies` report where each+ item is installed. Resolves IR-9.++### Changed++- `baikai-kit`: `KitConfig` gains the strict field `projectRoot`, so a record+ literal must set it; build the configuration with+ `kitConfig toolName repoUrl providers` instead and override fields with record+ update syntax. `KitConfig`'s `Show` instance is now hand-written and prints+ `<IO FilePath>` for the resolver. __Breaking__.++- `baikai-kit`: `kit status` conditions compose, and `dirty` is renamed+ `changed-upstream` (it meant the upstream sources changed without a version+ bump, not local edits); `dirty+outdated` now reads+ `outdated+changed-upstream`. `StatusRow.state :: KitState` is replaced by+ `StatusRow.conditions :: [KitCondition]` (sorted; empty means up to date),+ `renderState` by `conditionLabel` and `renderConditions`, and `classify`+ returns `[KitCondition]`. `KitUpToDate`, `KitDirty` and `KitDirtyOutdated`+ are gone; match on the list instead. __Breaking__.++- `baikai-kit`: `KitInstall` takes `Maybe Text` (`Nothing` asks the chooser),+ `kitCommandParser` takes the `KitConfig` (migration: `kitCommandParser`+ becomes `kitCommandParser myKitConfig`), `KitConfig` gains the `chooseItem`+ field (set by `kitConfig`), and `KitError` gains `KitItemNameRequired`.+ __Breaking__.++- `baikai-kit`: `KitList`, `KitStatus` and `KitUpdate` gain a trailing+ `OutputFormat` field (`HumanOutput` for the previous behaviour).+ __Breaking__.++## [baikai-effectful 0.4.0.2] - 2026-09-15++### Changed (dependencies)++- Requires `effectful-core ^>=2.7` (was `^>=2.6`). No API change: none of the+ 2.7 breaking APIs (`LocalEnv`'s second type parameter, `SharedSuffix`,+ `KnownEffects`, the ticked strict modules) are used.+ ## [baikai 0.7.0.0] - 2026-09-08 ### Added
baikai-kit.cabal view
@@ -1,27 +1,33 @@-cabal-version: 3.4-name: baikai-kit-version: 0.2.0.1-synopsis: Shared kit installer for AI-agent skills and subagents+cabal-version: 3.4+name: baikai-kit+version: 0.3.0.0+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-tested-with: GHC ==9.12.4+category: AI+license: BSD-3-Clause+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: (c) 2026 Nadeem Bitar+build-type: Simple+tested-with: ghc ==9.12.4 extra-doc-files: CHANGELOG.md common common-options ghc-options:- -Wall -Wcompat -Widentities -Wincomplete-uni-patterns- -Wincomplete-record-updates -Wredundant-constraints- -fhide-source-paths -Wmissing-export-lists -Wpartial-fields+ -Wall+ -Wcompat+ -Widentities+ -Wincomplete-uni-patterns+ -Wincomplete-record-updates+ -Wredundant-constraints+ -fhide-source-paths+ -Wmissing-export-lists+ -Wpartial-fields -Wmissing-deriving-strategies -- Exhaustiveness is an error, not a warning. A non-exhaustive match@@ -36,10 +42,11 @@ -- fail the build on warnings that are stylistic or that a future GHC -- invents, and would push people toward blanket suppression. ghc-options:- -Werror=incomplete-patterns -Werror=incomplete-uni-patterns+ -Werror=incomplete-patterns+ -Werror=incomplete-uni-patterns -Werror=incomplete-record-updates - default-language: GHC2024+ default-language: GHC2024 default-extensions: DeriveAnyClass DuplicateRecordFields@@ -47,14 +54,15 @@ OverloadedStrings library- import: common-options- hs-source-dirs: src+ import: common-options+ hs-source-dirs: src exposed-modules: Baikai.Kit Baikai.Kit.Command Baikai.Kit.Config Baikai.Kit.Error Baikai.Kit.Install+ Baikai.Kit.Json Baikai.Kit.Manifest Baikai.Kit.Path Baikai.Kit.Repo@@ -63,34 +71,39 @@ Baikai.Kit.Status build-depends:- , aeson ^>=2.2- , baikai ^>=0.7.0- , base >=4.20 && <5- , binary ^>=0.8- , bytestring ^>=0.12- , crypton ^>=1.0- , directory ^>=1.3- , filepath ^>=1.5- , optparse-applicative ^>=0.19- , process ^>=1.6- , text ^>=2.1- , time ^>=1.14+ aeson ^>=2.2,+ baikai ^>=0.7.0,+ base >=4.20 && <5,+ binary ^>=0.8,+ bytestring ^>=0.12,+ crypton ^>=1.0,+ directory ^>=1.3,+ filepath ^>=1.5,+ optparse-applicative ^>=0.19,+ process ^>=1.6,+ text ^>=2.1,+ time ^>=1.14, test-suite baikai-kit-test- import: common-options- type: exitcode-stdio-1.0+ import: common-options+ type: exitcode-stdio-1.0 hs-source-dirs: test- main-is: Main.hs- ghc-options: -threaded -with-rtsopts=-N+ 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+ aeson,+ baikai,+ baikai-kit,+ base,+ bytestring,+ directory,+ filepath,+ optparse-applicative,+ process,+ tasty,+ tasty-hunit,+ temporary,+ text,
src/Baikai/Kit.hs view
@@ -3,6 +3,7 @@ module Baikai.Kit.Config, module Baikai.Kit.Error, module Baikai.Kit.Install,+ module Baikai.Kit.Json, module Baikai.Kit.Manifest, module Baikai.Kit.Path, module Baikai.Kit.Repo,@@ -16,6 +17,7 @@ import Baikai.Kit.Config import Baikai.Kit.Error import Baikai.Kit.Install+import Baikai.Kit.Json import Baikai.Kit.Manifest import Baikai.Kit.Path import Baikai.Kit.Repo
src/Baikai/Kit/Command.hs view
@@ -5,6 +5,7 @@ -- see @docs/adr/0013-library-code-never-calls-exitfailure.md@. module Baikai.Kit.Command ( KitCommand (..),+ OutputFormat (..), kitCommandParser, runKit, runKitCommand,@@ -12,7 +13,7 @@ where import Baikai.Kit.Config (KitConfig, KitScope (..), scopeLabel)-import Baikai.Kit.Error (KitError, renderKitError)+import Baikai.Kit.Error (KitError (..), renderKitError) import Baikai.Kit.Install ( OverwritePolicy (..), UpdateReport,@@ -23,61 +24,98 @@ uninstallItem, updateKit, )-import Baikai.Kit.Manifest (itemKind, itemName)+import Baikai.Kit.Json (listDocument, statusDocument, updateDocument)+import Baikai.Kit.Manifest (KitManifest, itemKind, itemName) import Baikai.Kit.Repo (KitRepo, RepoRefresh (..), ensureKitRepo)-import Baikai.Kit.Status (StatusReport, UpstreamAvailability (..), kitStatus, renderStatusTable)+import Baikai.Kit.Status (StatusReport, UpstreamAvailability (..), installedCopies, kitStatus, renderStatusTable) import Baikai.Prelude+import Data.Aeson (Value)+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as LBS import Data.Text qualified as Text import Data.Text.IO qualified as Text.IO import Options.Applicative import System.Exit (ExitCode (ExitFailure), exitWith)-import System.IO (stderr)+import System.IO (Handle, stderr, stdout) +-- | How @list@, @status@ and @update@ print their result: the terminal+-- table, or exactly one JSON document on stdout (see "Baikai.Kit.Json").+data OutputFormat+ = HumanOutput+ | JsonOutput+ deriving stock (Eq, Show)+ data KitCommand- = KitList- | KitInstall !Text !KitScope- | KitUpdate !(Maybe Text) !OverwritePolicy+ = KitList !OutputFormat+ | -- | 'Nothing' asks the configured 'Baikai.Kit.Config.chooseItem'.+ KitInstall !(Maybe Text) !KitScope+ | KitUpdate !(Maybe Text) !OverwritePolicy !OutputFormat | KitUninstall !Text !KitScope- | KitStatus- deriving stock (Show)+ | KitStatus !OutputFormat+ deriving stock (Eq, Show) -kitCommandParser :: Parser KitCommand-kitCommandParser =+-- | The @kit@ subcommands. Takes the configuration so help text can name+-- the tool's own project directory.+kitCommandParser :: KitConfig -> Parser KitCommand+kitCommandParser config = hsubparser- ( command "list" (info (pure KitList) (progDesc "List available skills and subagents"))- <> command "install" (info installParser (progDesc "Install a skill or subagent"))+ ( command "list" (info (KitList <$> formatParser) (progDesc "List available skills and subagents"))+ <> command "install" (info (installParser config) (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"))+ <> command "uninstall" (info (uninstallParser config) (progDesc "Uninstall a skill or subagent"))+ <> command "status" (info (KitStatus <$> formatParser) (progDesc "Show installed skills and subagents")) )- <|> pure KitList+ <|> pure (KitList HumanOutput) -- | Run one verb and print its normal output. Never exits, so a consumer -- that wants its own exit codes can map the 'KitError' itself. runKitCommand :: KitConfig -> KitCommand -> IO (Either KitError ()) runKitCommand config = \case- KitList -> withRepo $ \repo ->+ KitList HumanOutput -> withRepo HumanOutput $ \repo -> loadManifest (repo ^. #dir) `thenE` \manifest -> printed (renderAvailable manifest)- KitInstall n scope -> withRepo $ \repo ->+ KitList JsonOutput -> withRepo JsonOutput $ \repo ->+ loadManifest (repo ^. #dir) `thenE` \manifest -> do+ copies <- installedCopies config+ emit (listDocument (repoAvailability repo) manifest copies)+ KitInstall (Just n) scope -> withRepo HumanOutput $ \repo -> loadManifest (repo ^. #dir) `thenE` \manifest ->- installFrom config (repo ^. #dir) manifest n scope `thenE` \item ->- printed $- "Installed " <> itemKind item <> " '" <> itemName item <> "' to " <> scopeLabel scope <> " scope."- KitUpdate n policy ->+ installNamed repo manifest n scope+ -- Without a chooser nothing the refresh could do changes the outcome,+ -- so fail before touching the network.+ KitInstall Nothing scope -> case config ^. #chooseItem of+ Nothing -> pure (Left KitItemNameRequired)+ Just choose -> withRepo HumanOutput $ \repo ->+ loadManifest (repo ^. #dir) `thenE` \manifest -> do+ picked <- choose manifest+ case picked of+ Nothing -> printed "No item chosen; nothing installed."+ Just n -> installNamed repo manifest n scope+ KitUpdate n policy HumanOutput -> updateKit config n policy `thenE` (printed . renderUpdateReport)+ KitUpdate n policy JsonOutput ->+ updateKit config n policy `thenE` (emit . updateDocument) KitUninstall n scope -> uninstallItem config n scope `thenE` (printed . renderUninstallReport n scope)- KitStatus -> do+ KitStatus format -> do report <- kitStatus config noteUpstream report- Text.IO.putStrLn (renderStatusTable (report ^. #rows))- pure (Right ())+ case format of+ HumanOutput -> printed (renderStatusTable (report ^. #rows))+ JsonOutput -> emit (statusDocument report) where+ installNamed :: KitRepo -> KitManifest -> Text -> KitScope -> IO (Either KitError ())+ installNamed repo manifest n scope =+ installFrom config (repo ^. #dir) manifest n scope `thenE` \item ->+ printed $+ "Installed " <> itemKind item <> " '" <> itemName item <> "' to " <> scopeLabel scope <> " scope."+ -- List and install need the manifest, so a repository they cannot -- reach is an error; a stale cache is a warning and the work goes on.- withRepo :: (KitRepo -> IO (Either KitError ())) -> IO (Either KitError ())- withRepo next = do+ -- In JSON mode stdout carries only the document, so the clone notice+ -- goes to stderr with the warnings.+ withRepo :: OutputFormat -> (KitRepo -> IO (Either KitError ())) -> IO (Either KitError ())+ withRepo format next = do repo <- ensureKitRepo config case repo of Left err -> pure (Left err)@@ -86,7 +124,8 @@ RepoStale err -> Text.IO.hPutStrLn stderr $ "Warning: kit repository could not be refreshed (" <> Text.strip err <> "); using the cached copy."- RepoCloned -> Text.IO.putStrLn ("Fetched " <> (config ^. #toolName) <> "-kit.")+ RepoCloned ->+ Text.IO.hPutStrLn (noticeHandle format) ("Fetched " <> (config ^. #toolName) <> "-kit.") RepoPulled -> pure () next resolved @@ -110,6 +149,20 @@ printed :: Text -> IO (Either KitError ()) printed message = Right <$> Text.IO.putStrLn message + -- One document, UTF-8 encoded whatever the locale (ADR 0007).+ emit :: Value -> IO (Either KitError ())+ emit document = Right <$> LBS.hPut stdout (Aeson.encode document <> "\n")++ noticeHandle :: OutputFormat -> Handle+ noticeHandle HumanOutput = stdout+ noticeHandle JsonOutput = stderr++ repoAvailability :: KitRepo -> UpstreamAvailability+ repoAvailability repo = case repo ^. #refresh of+ RepoStale err -> UpstreamStale err+ RepoCloned -> UpstreamReady+ RepoPulled -> UpstreamReady+ -- | The command adapter: 'runKitCommand', then on 'Left' print -- @Error: \<renderKitError e\>@ to stderr and exit 1. This is the only -- function in @baikai-kit@ that exits the process.@@ -152,11 +205,16 @@ | null (report ^. #skipped) = [] | otherwise = ["Skipped " <> Text.pack (show (length (report ^. #skipped))) <> " item(s)."] -installParser :: Parser KitCommand-installParser =+installParser :: KitConfig -> Parser KitCommand+installParser config = KitInstall- <$> strArgument (metavar "NAME" <> help "Name of the skill or subagent to install")- <*> scopeParser "Install to project scope instead of user scope"+ <$> optional+ ( strArgument+ ( metavar "NAME"+ <> help "Name of the skill or subagent to install; omit it to choose interactively if this tool offers a chooser"+ )+ )+ <*> scopeParser ("Install to project scope (" <> projectDirLabel config <> " under the project root) instead of user scope") updateParser :: Parser KitCommand updateParser =@@ -166,13 +224,21 @@ KeepLocalEdits OverwriteLocalEdits (long "force" <> help "Reinstall items even if their installed files were modified locally")+ <*> formatParser -uninstallParser :: Parser KitCommand-uninstallParser =+formatParser :: Parser OutputFormat+formatParser = flag HumanOutput JsonOutput (long "json" <> help "Print one JSON document on stdout")++uninstallParser :: KitConfig -> Parser KitCommand+uninstallParser config = KitUninstall <$> strArgument (metavar "NAME" <> help "Name of the skill or subagent to uninstall")- <*> scopeParser "Uninstall from project scope instead of user scope"+ <*> scopeParser ("Uninstall from project scope (" <> projectDirLabel config <> ") instead of user scope") scopeParser :: String -> Parser KitScope scopeParser helpText = flag UserScope ProjectScope (long "project" <> help helpText)++-- | The tool's project directory as help text shows it, e.g. @.mytool/agents@.+projectDirLabel :: KitConfig -> String+projectDirLabel config = "." <> Text.unpack (config ^. #toolName) <> "/agents"
src/Baikai/Kit/Config.hs view
@@ -1,6 +1,9 @@ module Baikai.Kit.Config ( KitConfig (..), KitScope (..),+ kitConfig,+ findProjectRoot,+ projectRootByMarkers, kitCacheDir, userAgentsDir, projectAgentsDir,@@ -14,18 +17,87 @@ import Baikai.AgentAssets (AgentAssetProvider) import Baikai.Interactive (InteractiveProvider (..))+import Baikai.Kit.Manifest (KitManifest) import Baikai.Prelude+import Data.Maybe (fromMaybe) import Data.Text qualified as Text-import System.Directory (getCurrentDirectory, getHomeDirectory)-import System.FilePath ((</>))+import System.Directory (doesPathExist, getCurrentDirectory, getHomeDirectory, makeAbsolute)+import System.FilePath (takeDirectory, (</>)) +-- | How a tool configures the kit engine. Build one with 'kitConfig' and+-- override optional fields with record update syntax; a record literal+-- must set every field. data KitConfig = KitConfig { toolName :: !Text, repoUrl :: !Text,- providers :: ![AgentAssetProvider]+ providers :: ![AgentAssetProvider],+ -- | The directory project scope lives under. It is run once per+ -- project-scope path lookup, so install, status, update, uninstall,+ -- and 'Baikai.Kit.Session.agentDirsForSession' all agree on it. The+ -- default ('kitConfig') is the current directory; 'projectRootByMarkers'+ -- walks up to the nearest marker such as @.git@. An exception thrown+ -- by this action propagates to the caller.+ projectRoot :: !(IO FilePath),+ -- | Called by @kit install@ when no name is given, with the whole+ -- manifest. 'Just' a name installs that item (a name the manifest+ -- does not list fails with 'Baikai.Kit.Error.KitItemNotFound');+ -- 'Nothing' means the user cancelled, and nothing is installed. When+ -- this field is 'Nothing', @kit install@ without a name fails with+ -- 'Baikai.Kit.Error.KitItemNameRequired'. The engine ships no picker:+ -- the tool owns presentation. An exception thrown by the chooser+ -- propagates to the caller.+ chooseItem :: !(Maybe (KitManifest -> IO (Maybe Text))) }- deriving stock (Generic, Show)+ deriving stock (Generic) +instance Show KitConfig where+ showsPrec d config =+ showParen (d > 10) $+ showString "KitConfig {toolName = "+ . shows (config ^. #toolName)+ . showString ", repoUrl = "+ . shows (config ^. #repoUrl)+ . showString ", providers = "+ . shows (config ^. #providers)+ . showString ", projectRoot = <IO FilePath>, chooseItem = "+ . showString (maybe "Nothing" (const "Just <chooser>") (config ^. #chooseItem))+ . showString "}"++-- | A configuration with every optional behaviour at its default:+-- project scope is the current directory, and @kit install@ requires a+-- name (no chooser).+kitConfig :: Text -> Text -> [AgentAssetProvider] -> KitConfig+kitConfig toolName repoUrl providers =+ KitConfig+ { toolName,+ repoUrl,+ providers,+ projectRoot = getCurrentDirectory,+ chooseItem = Nothing+ }++-- | The nearest directory, starting at @start@ and walking towards the+-- filesystem root, that contains any of @markers@ (a file or a+-- directory, e.g. @.git@ or @.mytool@). 'Nothing' if none does.+findProjectRoot :: [FilePath] -> FilePath -> IO (Maybe FilePath)+findProjectRoot markers start = makeAbsolute start >>= go+ where+ go dir = do+ found <- or <$> traverse (doesPathExist . (dir </>)) markers+ if found+ then pure (Just dir)+ else+ let parent = takeDirectory dir+ in if parent == dir then pure Nothing else go parent++-- | A ready-made 'projectRoot': the nearest ancestor of the current+-- directory holding one of @markers@, or the current directory itself+-- when there is none.+projectRootByMarkers :: [FilePath] -> IO FilePath+projectRootByMarkers markers = do+ cwd <- getCurrentDirectory+ fromMaybe cwd <$> findProjectRoot markers cwd+ data KitScope = UserScope | ProjectScope@@ -41,10 +113,12 @@ home <- getHomeDirectory pure (home </> ".config" </> Text.unpack (config ^. #toolName) </> "agents") +-- | Every project-scope path derives from 'projectRoot' through this+-- function, 'resolveAgentsBase', or 'providerAgentsBase'. projectAgentsDir :: KitConfig -> IO FilePath projectAgentsDir config = do- cwd <- getCurrentDirectory- pure (cwd </> "." <> Text.unpack (config ^. #toolName) </> "agents")+ root <- config ^. #projectRoot+ pure (root </> "." <> Text.unpack (config ^. #toolName) </> "agents") resolveAgentsBase :: KitConfig -> KitScope -> IO FilePath resolveAgentsBase config UserScope = userAgentsDir config@@ -53,7 +127,7 @@ providerAgentsBase :: KitConfig -> AgentAssetProvider -> KitScope -> IO FilePath providerAgentsBase config InteractiveClaude scope = resolveAgentsBase config scope providerAgentsBase _config InteractiveCodex UserScope = getHomeDirectory-providerAgentsBase _config InteractiveCodex ProjectScope = getCurrentDirectory+providerAgentsBase config InteractiveCodex ProjectScope = config ^. #projectRoot providerLabel :: AgentAssetProvider -> Text providerLabel InteractiveClaude = "claude"
src/Baikai/Kit/Error.hs view
@@ -24,6 +24,9 @@ KitManifestVersionUnsupported FilePath Int | -- | No skill or agent of that name is listed. KitItemNotFound Text+ | -- | @kit install@ was given no name and the tool supplies no chooser+ -- ('Baikai.Kit.Config.chooseItem' is 'Nothing').+ KitItemNameRequired | -- | The item lists no source files. KitItemHasNoFiles Text | -- | An item name failed 'Baikai.Kit.Path.safeItemName'; the second@@ -70,6 +73,8 @@ <> Text.pack (show n) <> "; this installer supports versions 1 and 2." KitItemNotFound n -> "'" <> n <> "' not found in kit manifest."+ KitItemNameRequired ->+ "no item name given: pass NAME to 'kit install' (run 'kit list' to see what is available)." KitItemHasNoFiles n -> "'" <> n <> "' lists no source files." KitUnsafeName raw reason -> "unsafe item name '" <> raw <> "': " <> reason KitUnsafePath raw reason -> "unsafe manifest path '" <> raw <> "': " <> reason
src/Baikai/Kit/Install.hs view
@@ -18,6 +18,8 @@ UpdateReport (..), updateKit, reinstallPresent,+ LocalEdits (..),+ checkLocalEdits, listAvailable, renderAvailable, PlannedWrite (..),@@ -29,7 +31,8 @@ where import Baikai.AgentAssets- ( CodexCustomAgent (..),+ ( AgentAssetProvider,+ CodexCustomAgent (..), agentTargetPath, codexCustomAgentToml, skillTargetPath,@@ -424,30 +427,54 @@ map (view #name) (manifest ^. #skills) ++ map (view #name) (manifest ^. #agents) --- | Do this item's installed files still hash to what its sidecar--- recorded? A file that cannot be read counts as modified; a sidecar--- without the recorded names and hash is not checked.+-- | Whether one provider's installed copy of an item still matches what+-- was installed.+data LocalEdits+ = -- | Every recorded file reads back with the recorded hash.+ Unedited+ | -- | A recorded file differs, or cannot be read.+ Edited+ | -- | There is nothing to compare against: no sidecar, or a sidecar+ -- written before @installedFiles@ and @installedHash@ existed.+ EditsUnknown+ deriving stock (Eq, Show)++-- | Do one provider's installed files for an item still hash to what its+-- sidecar recorded? This is the one local-edit check: @kit update@ skips+-- an 'Edited' item under 'KeepLocalEdits', and @kit status@ reports it+-- as modified. Reads only local files.+checkLocalEdits ::+ KitConfig -> AgentAssetProvider -> KitScope -> KitItemKind -> Text -> IO (Either KitError LocalEdits)+checkLocalEdits config provider scope kind n = kitTry (localEditsIO config provider scope kind n)++localEditsIO :: KitConfig -> AgentAssetProvider -> KitScope -> KitItemKind -> Text -> IO LocalEdits+localEditsIO config provider scope kind n = do+ safeName <- orThrow (KitUnsafeName n) (safeItemName n)+ providerBase <- providerAgentsBase config provider scope+ let root = installedRoot config provider providerBase kind safeName+ mSidecar <- readSidecar (sidecarPath provider kind (Text.pack safeName) providerBase (sidecarFileName config))+ case mSidecar of+ Nothing -> pure EditsUnknown+ Just sidecar ->+ case (sidecar ^. #installedFiles, sidecar ^. #installedHash) of+ (Just recorded, Just expected) -> do+ entries <- forM recorded $ \rel -> do+ bytes <- try @IOException (BS.readFile (root </> Text.unpack rel))+ pure (either (const Nothing) (Just . (Text.unpack rel,)) (bytes :: Either IOException BS.ByteString))+ pure $ case sequence entries of+ Nothing -> Edited+ Just pairs+ | hashEntries pairs /= expected -> Edited+ | otherwise -> Unedited+ _ -> pure EditsUnknown++-- | Was any provider's copy of this item edited locally? A missing or+-- legacy sidecar is not an edit. locallyModified :: KitConfig -> KitItem -> KitScope -> IO Bool locallyModified config item scope = do- safeName <- orThrow (KitUnsafeName (itemName item)) (safeItemName (itemName item))- checks <- forM (config ^. #providers) $ \provider -> do- providerBase <- providerAgentsBase config provider scope- let kind = kitItemKind item- root = installedRoot config provider providerBase kind safeName- mSidecar <- readSidecar (sidecarPath provider kind (Text.pack safeName) providerBase (sidecarFileName config))- case mSidecar of- Nothing -> pure False- Just sidecar ->- case (sidecar ^. #installedFiles, sidecar ^. #installedHash) of- (Just recorded, Just expected) -> do- entries <- forM recorded $ \rel -> do- bytes <- try @IOException (BS.readFile (root </> Text.unpack rel))- pure (either (const Nothing) (Just . (Text.unpack rel,)) (bytes :: Either IOException BS.ByteString))- pure $ case sequence entries of- Nothing -> True- Just pairs -> hashEntries pairs /= expected- _ -> pure False- pure (or checks)+ checks <- forM (config ^. #providers) $ \provider ->+ localEditsIO config provider scope (kitItemKind item) (itemName item)+ pure (Edited `elem` checks) doInstall :: KitConfig -> FilePath -> KitItem -> KitScope -> IO () doInstall config repoDir item scope = do
+ src/Baikai/Kit/Json.hs view
@@ -0,0 +1,142 @@+-- | The machine-readable documents @kit list --json@, @kit status --json@+-- and @kit update --json@ print, as explicit encoders.+--+-- These shapes are a public, versioned contract, pinned by golden tests+-- in @baikai-kit/test/golden/@. They are written here by hand rather+-- than derived, so renaming a Haskell field cannot change them. Every+-- document carries 'kitJsonFormatVersion' and a @document@ name. Adding+-- a key keeps the version; removing or renaming one, or changing what a+-- value means, increments it. Every documented key is always present,+-- with @null@ for an absent value.+--+-- See @docs/adr/0024-machine-readable-kit-output-is-a-versioned-contract.md@.+module Baikai.Kit.Json+ ( kitJsonFormatVersion,+ listDocument,+ statusDocument,+ updateDocument,+ )+where++import Baikai.Kit.Config (KitScope, providerLabel, scopeLabel)+import Baikai.Kit.Error (renderKitError)+import Baikai.Kit.Install (UpdateReport)+import Baikai.Kit.Manifest (KitItemKind (..), KitManifest, kindLabel)+import Baikai.Kit.Repo (RepoRefresh (..))+import Baikai.Kit.Status+ ( InstalledCopy,+ StatusReport,+ StatusRow,+ UpstreamAvailability (..),+ conditionLabel,+ )+import Baikai.Prelude hiding ((.=))+import Data.Aeson (Value (Null), object, (.=))+import Data.List (sortOn)++-- | The @formatVersion@ every document carries.+kitJsonFormatVersion :: Int+kitJsonFormatVersion = 1++-- | @kit-list@: what the kit offers, skills then agents in manifest order,+-- each with the copies installed of it (sorted user before project,+-- then by provider).+listDocument :: UpstreamAvailability -> KitManifest -> [InstalledCopy] -> Value+listDocument availability manifest copies =+ object+ [ "formatVersion" .= kitJsonFormatVersion,+ "document" .= ("kit-list" :: Text),+ "upstream" .= upstreamValue availability,+ "items"+ .= ( [ itemValue SkillKind (entry ^. #name) (entry ^. #description) (entry ^. #version)+ | entry <- manifest ^. #skills+ ]+ ++ [ itemValue AgentKind (entry ^. #name) (entry ^. #description) (entry ^. #version)+ | entry <- manifest ^. #agents+ ]+ )+ ]+ where+ itemValue :: KitItemKind -> Text -> Text -> Maybe Text -> Value+ itemValue kind n description version =+ object+ [ "name" .= n,+ "kind" .= kindLabel kind,+ "description" .= description,+ "version" .= version,+ "installed"+ .= map+ copyValue+ ( sortOn+ (\copy -> (copy ^. #scope, providerLabel (copy ^. #provider)))+ [copy | copy <- copies, copy ^. #name == n, copy ^. #kind == kind]+ )+ ]+ copyValue :: InstalledCopy -> Value+ copyValue copy =+ object+ [ "scope" .= scopeLabel (copy ^. #scope),+ "provider" .= providerLabel (copy ^. #provider),+ "version" .= (copy ^. #version),+ "path" .= (copy ^. #path)+ ]++-- | @kit-status@: one entry per installed copy — item, scope, and+-- provider — never aggregated, sorted by name, kind, scope, provider.+statusDocument :: StatusReport -> Value+statusDocument report =+ object+ [ "formatVersion" .= kitJsonFormatVersion,+ "document" .= ("kit-status" :: Text),+ "upstream" .= upstreamValue (report ^. #upstream),+ "items" .= map rowValue (sortOn rowKey (report ^. #rows))+ ]+ where+ rowKey row = (row ^. #name, row ^. #kind, row ^. #scope, row ^. #providers)+ rowValue :: StatusRow -> Value+ rowValue row =+ object+ [ "name" .= (row ^. #name),+ "kind" .= (row ^. #kind),+ "scope" .= (row ^. #scope),+ "provider" .= (row ^. #providers),+ "installedVersion" .= (row ^. #installedVersion),+ "latestVersion" .= (row ^. #latestVersion),+ "conditions" .= map conditionLabel (row ^. #conditions),+ "upToDate" .= null (row ^. #conditions)+ ]++-- | @kit-update@: how the cache was refreshed and what was updated or+-- skipped, in the report's order.+updateDocument :: UpdateReport -> Value+updateDocument report =+ object+ [ "formatVersion" .= kitJsonFormatVersion,+ "document" .= ("kit-update" :: Text),+ "refresh" .= fmap refreshLabel (report ^. #refresh),+ "updated" .= map updatedValue (report ^. #updated),+ "skipped" .= map skippedValue (report ^. #skipped)+ ]+ where+ refreshLabel :: RepoRefresh -> Text+ refreshLabel = \case+ RepoCloned -> "cloned"+ RepoPulled -> "pulled"+ RepoStale _ -> "stale"+ updatedValue :: (Text, KitScope) -> Value+ updatedValue (n, scope) = object ["name" .= n, "scope" .= scopeLabel scope]+ -- 'UpdateReport' skips an item only for local edits today; the reason+ -- is spelled out so a later reason is an added value, not a new key.+ skippedValue :: (Text, KitScope) -> Value+ skippedValue (n, scope) =+ object+ [ "name" .= n,+ "scope" .= scopeLabel scope,+ "reason" .= ("locally-modified" :: Text)+ ]++upstreamValue :: UpstreamAvailability -> Value+upstreamValue = \case+ UpstreamReady -> object ["state" .= ("ready" :: Text), "detail" .= Null]+ UpstreamStale detail -> object ["state" .= ("stale" :: Text), "detail" .= detail]+ UpstreamUnavailable err -> object ["state" .= ("unavailable" :: Text), "detail" .= renderKitError err]
src/Baikai/Kit/Status.hs view
@@ -1,12 +1,15 @@ module Baikai.Kit.Status- ( KitState (..),+ ( KitCondition (..),+ InstalledCopy (..), StatusReport (..), StatusRow (..), UpstreamAvailability (..), classify, collectStatus,+ installedCopies, kitStatus,- renderState,+ conditionLabel,+ renderConditions, renderStatusTable, ) where@@ -15,27 +18,42 @@ import Baikai.Interactive (InteractiveScope (InteractiveProjectScope)) import Baikai.Kit.Config (KitConfig, KitScope (..), providerAgentsBase, providerLabel, sidecarFileName) import Baikai.Kit.Error (KitError (..))-import Baikai.Kit.Install (loadManifestMaybe, lookupItem)+import Baikai.Kit.Install (LocalEdits (..), checkLocalEdits, loadManifestMaybe, lookupItem) import Baikai.Kit.Manifest (KitItem, KitItemKind (..), itemKind, itemSources, itemVersion, kindLabel) import Baikai.Kit.Repo (RepoRefresh (..), ensureKitRepo) import Baikai.Kit.Sidecar (SidecarMeta, computeKitHash, readSidecar, sidecarPath) import Baikai.Prelude import Control.Monad (forM) import Data.List (groupBy, isPrefixOf, isSuffixOf, nub, sort, sortOn)-import Data.Maybe (fromMaybe)+import Data.Maybe (fromMaybe, isNothing) import Data.Text qualified as Text import System.Directory (doesDirectoryExist, listDirectory) import System.FilePath (takeDirectory, (</>)) -data KitState- = KitUpToDate- | KitOutdated- | KitDirty- | KitDirtyOutdated- | KitDelisted- | KitUpstreamRefused- | KitUnknown- deriving stock (Eq, Ord, Show)+-- | One thing @kit status@ can say about an installed copy. A row carries+-- a sorted, duplicate-free list of these; an empty list means the copy+-- is up to date. The order of the constructors is the order the labels+-- are rendered in.+data KitCondition+ = -- | @unknown@: no readable sidecar, so nothing can be compared.+ KitUnknown+ | -- | @delisted@: the manifest no longer lists the item.+ KitDelisted+ | -- | @refused@: the upstream lists a source the installer refuses — a+ -- symbolic link, or a path outside the kit.+ KitUpstreamRefused+ | -- | @outdated@: the manifest version differs from the installed one.+ KitOutdated+ | -- | @changed-upstream@: the upstream sources changed since install+ -- without a version change. @kit update@ reinstalls it.+ KitChangedUpstream+ | -- | @modified@: installed files were edited since install. @kit update@+ -- skips it unless @--force@.+ KitLocallyModified+ | -- | @edits-unknown@: the sidecar predates the installed-file hash, so+ -- local edits cannot be detected.+ KitLocalEditsUnknown+ deriving stock (Eq, Ord, Show, Enum, Bounded) -- | Whether the cached upstream could be consulted for this report. data UpstreamAvailability@@ -63,35 +81,43 @@ providers :: !Text, installedVersion :: !(Maybe Text), latestVersion :: !(Maybe Text),- state :: !KitState+ -- | Sorted and duplicate-free; empty means up to date.+ conditions :: ![KitCondition] } deriving stock (Eq, Generic, Show) -renderState :: KitState -> Text-renderState = \case- KitUpToDate -> "up-to-date"- KitOutdated -> "outdated"- KitDirty -> "dirty"- KitDirtyOutdated -> "dirty+outdated"+-- | The stable spelling of a condition, shared by the status table and any+-- machine-readable output.+conditionLabel :: KitCondition -> Text+conditionLabel = \case+ KitUnknown -> "unknown" KitDelisted -> "delisted" KitUpstreamRefused -> "refused"- KitUnknown -> "unknown"+ KitOutdated -> "outdated"+ KitChangedUpstream -> "changed-upstream"+ KitLocallyModified -> "modified"+ KitLocalEditsUnknown -> "edits-unknown" -classify :: Maybe SidecarMeta -> Maybe KitItem -> Maybe Text -> KitState-classify Nothing _ _ = KitUnknown-classify (Just _) Nothing _ = KitDelisted+-- | @up-to-date@ for no conditions; otherwise the labels in constructor+-- order joined with @+@, e.g. @outdated+changed-upstream+modified@.+renderConditions :: [KitCondition] -> Text+renderConditions [] = "up-to-date"+renderConditions conds = Text.intercalate "+" (map conditionLabel (sort (nub conds)))++-- | The conditions that compare an installed copy with the upstream:+-- whether it is known, listed, outdated, or changed upstream. Local+-- edits are checked separately, by 'checkLocalEdits'.+classify :: Maybe SidecarMeta -> Maybe KitItem -> Maybe Text -> [KitCondition]+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+ changed = 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+ in [KitOutdated | outdated] ++ [KitChangedUpstream | changed] -- | Collect the status of everything installed. Needs no network: a kit -- repository that cannot be reached is reported as@@ -127,9 +153,18 @@ let mItem = lookupItem itemName' =<< mManifest mSidecar <- readSidecar (sidecarPath provider scannedKind itemName' baseDir (sidecarFileName config)) upstream <- upstreamHash cacheDir mItem- let state' = case upstream of- Left _ -> KitUpstreamRefused+ let upstreamConditions = case upstream of+ Left _ -> KitUpstreamRefused : [KitUnknown | isNothing mSidecar] Right mUpstreamHash -> classify mSidecar mItem mUpstreamHash+ localConditions <- case mSidecar of+ Nothing -> pure []+ Just _ -> do+ edits <- checkLocalEdits config provider scope scannedKind itemName'+ pure $ case edits of+ Right Unedited -> []+ Right Edited -> [KitLocallyModified]+ Right EditsUnknown -> [KitLocalEditsUnknown]+ Left _ -> [KitLocalEditsUnknown] pure StatusRow { name = itemName',@@ -138,9 +173,43 @@ providers = providerLabel provider, installedVersion = mSidecar >>= (^. #version), latestVersion = mItem >>= itemVersion,- state = state'+ conditions = sort (nub (upstreamConditions ++ localConditions)) } +-- | One item at one scope for one provider, and where it is.+data InstalledCopy = InstalledCopy+ { name :: !Text,+ kind :: !KitItemKind,+ scope :: !KitScope,+ provider :: !AgentAssetProvider,+ -- | The skill directory or the agent file.+ path :: !FilePath,+ -- | From the sidecar; 'Nothing' without a readable one.+ version :: !(Maybe Text)+ }+ deriving stock (Eq, Generic, Show)++-- | Every installed copy at user and project scope, in filesystem order.+-- Reads only local files.+installedCopies :: KitConfig -> IO [InstalledCopy]+installedCopies config =+ fmap concat . forM [UserScope, ProjectScope] $ \scope -> do+ items <- scanInstalled config scope+ forM items $ \(provider, baseDir, itemName', scannedKind) -> do+ mSidecar <- readSidecar (sidecarPath provider scannedKind itemName' baseDir (sidecarFileName config))+ let relative = case scannedKind of+ SkillKind -> skillTargetPath provider InteractiveProjectScope (Text.unpack itemName')+ AgentKind -> agentTargetPath provider InteractiveProjectScope (Text.unpack itemName')+ pure+ InstalledCopy+ { name = itemName',+ kind = scannedKind,+ scope,+ provider,+ path = baseDir </> relative,+ version = mSidecar >>= (^. #version)+ }+ -- | The hash of an item's sources as they are in the cached checkout. -- -- @Right Nothing@ means there is nothing to compare against: no cache,@@ -221,7 +290,7 @@ <> Text.justifyLeft (providersW + 2) ' ' (row ^. #providers) <> Text.justifyLeft (instW + 2) ' ' (renderMVer (row ^. #installedVersion)) <> Text.justifyLeft (latW + 2) ' ' (renderMVer (row ^. #latestVersion))- <> renderState (row ^. #state)+ <> renderConditions (row ^. #conditions) aggregateStatusRows :: [StatusRow] -> [StatusRow] aggregateStatusRows rows =@@ -234,7 +303,7 @@ row ^. #scope, row ^. #installedVersion, row ^. #latestVersion,- row ^. #state+ row ^. #conditions ) sameKey a b = rowKey a == rowKey b summarize groupRows@(firstRow : _) =
test/Main.hs view
@@ -6,67 +6,104 @@ import Baikai.Kit ( AgentEntry (..), KitCommand (..),+ KitCondition (..), KitConfig (..), KitError (..), KitItem (..), KitItemKind (..), KitManifest (..),- KitScope (UserScope),- KitState (..),+ KitScope (..),+ OutputFormat (..), OverwritePolicy (..), PlannedWrite (..), PullResult (..), RemovalOutcome (..),+ RepoRefresh (..), SidecarMeta (..), SkillEntry (..), UpstreamAvailability (..), WriteContent (..),+ agentDirsForSession, classify, collectStatus, computeKitHash,+ conditionLabel, executePlanWith,+ findProjectRoot, installItem,+ installedCopies,+ kitCommandParser,+ kitConfig,+ kitJsonFormatVersion, kitStatus,+ listDocument, loadManifest,+ projectRootByMarkers, pullKitRepo, readSidecar, reinstallPresent,- renderState,+ renderConditions, renderUninstallReport, runKit,+ runKitCommand, safeItemName, safeRelativePath, safeSourcePath, sidecarFileName, sidecarPath,+ statusDocument, stripYamlFrontmatter, uninstallItem,+ updateDocument, updateKit, ) import Baikai.Prelude+import Control.Concurrent (threadDelay) import Control.Exception (finally, try)+import Control.Monad (forM_, void)+import Data.Aeson (Value (..)) import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString qualified as BS-import Data.List (find, isSuffixOf)+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.List (find, isInfixOf, isSuffixOf, nub, sort) import Data.Text qualified as Text import Data.Text.Encoding qualified as Text.Encoding+import GHC.IO.Handle (hDuplicate, hDuplicateTo)+import Options.Applicative+ ( ParserResult (..),+ defaultPrefs,+ execParserPure,+ getParseResult,+ helper,+ info,+ renderFailure,+ (<**>),+ ) import System.Directory- ( createDirectoryIfMissing,+ ( canonicalizePath,+ createDirectoryIfMissing, createDirectoryLink, doesDirectoryExist, doesFileExist, doesPathExist,+ getCurrentDirectory, listDirectory, removeDirectoryRecursive, removeFile, renameFile,+ withCurrentDirectory, ) import System.Environment (lookupEnv, setEnv, unsetEnv) import System.Exit (ExitCode (..)) import System.FilePath (takeDirectory, (</>))-import System.IO.Temp (withSystemTempDirectory)+import System.IO (hClose, hFlush, stdout)+import System.IO.Temp (withSystemTempDirectory, withSystemTempFile)+import System.Process (readProcessWithExitCode) import Test.Tasty (TestTree, defaultMain, localOption, testGroup)-import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))+import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=)) import Test.Tasty.Runners (NumThreads (NumThreads)) main :: IO ()@@ -84,7 +121,10 @@ statusFilesystemTests, installRoundTripTests, typedErrorTests,- installFidelityTests+ installFidelityTests,+ projectRootTests,+ commandTests,+ jsonTests ] manifestTests :: TestTree@@ -131,39 +171,42 @@ testGroup "Status.classify" [ testCase "no sidecar => unknown" $- classify Nothing (Just (mkSkillItem "foo" (Just "1.0"))) (Just "h") @?= KitUnknown,+ 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,+ 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" $+ @?= [KitOutdated],+ testCase "version and hash mismatch => outdated+changed-upstream" $ classify (Just (mkSidecar (Just "1.0") "h1")) (Just (mkSkillItem "foo" (Just "2.0"))) (Just "h2")- @?= KitDirtyOutdated,- testCase "hash mismatch => dirty" $+ @?= [KitOutdated, KitChangedUpstream],+ testCase "hash mismatch => changed-upstream" $ classify (Just (mkSidecar (Just "1.0") "h1")) (Just (mkSkillItem "foo" (Just "1.0"))) (Just "h2")- @?= KitDirty,+ @?= [KitChangedUpstream], 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+ @?= [],+ testCase "renderConditions joins labels in order" $ do+ renderConditions [] @?= "up-to-date"+ renderConditions [KitOutdated, KitChangedUpstream, KitLocallyModified] @?= "outdated+changed-upstream+modified" ] pathSafetyTests :: TestTree@@ -187,7 +230,7 @@ result <- installItem testConfig "evil" UserScope assertKitError "KitUnsafePath" isUnsafePath result assertFileMissing (takeDirectory home </> "escape.txt")- exitResult <- try @ExitCode (runKit testConfig (KitInstall "evil" UserScope))+ exitResult <- try @ExitCode (runKit testConfig (KitInstall (Just "evil") UserScope)) exitResult @?= Left (ExitFailure 1), testCase "uninstall refuses a traversal name" $ withPreparedKitHome $ \home _cache -> do@@ -236,9 +279,9 @@ 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 @?= KitUpstreamRefused) demoRows,- testCase "renderState names the refused state" $- renderState KitUpstreamRefused @?= "refused"+ mapM_ (\row -> row ^. #conditions @?= [KitUpstreamRefused]) demoRows,+ testCase "conditionLabel names the refused condition" $+ conditionLabel KitUpstreamRefused @?= "refused" ] frontmatterTests :: TestTree@@ -277,9 +320,9 @@ 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 ^. #conditions @?= [KitDelisted]) demoRows mapM_ (\row -> row ^. #installedVersion @?= Just "0.1.0") demoRows,- testCase "version and cached hash drift reports dirty+outdated" $+ testCase "version and cached hash drift reports outdated+changed-upstream" $ withPreparedKitHome $ \_home cache -> do _ <- assertRight =<< installItem testConfig "demo" UserScope BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "changed instructions\n"@@ -287,8 +330,64 @@ 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+ mapM_ (\row -> row ^. #conditions @?= [KitOutdated, KitChangedUpstream]) demoRows,+ testCase "an installed item reports no conditions before an edit" $+ withPreparedKitHome $ \_home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ let demoRows = filter ((== "demo") . view #name) rows+ assertBool "expected demo status rows" (not (null demoRows))+ mapM_ (\row -> row ^. #conditions @?= []) demoRows,+ testCase "editing an installed file reports modified" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope+ BS.writeFile (userClaudeSkill home </> "SKILL.md") "my edits"+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ demoConditions rows "claude" >>= (@?= [KitLocallyModified])+ demoConditions rows "codex" >>= (@?= []),+ testCase "a legacy sidecar reports edits-unknown" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope+ BS.writeFile (userClaudeSkill home </> ".testkit-kit.json") legacySidecarJson+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ conditions <- demoConditions rows "claude"+ assertBool ("expected edits-unknown in " <> show conditions) (KitLocalEditsUnknown `elem` conditions)+ assertBool ("expected no modified in " <> show conditions) (KitLocallyModified `notElem` conditions),+ testCase "modified composes with outdated and changed-upstream" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope+ BS.writeFile (userClaudeSkill home </> "SKILL.md") "my edits"+ BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "changed instructions\n"+ BS.writeFile (cache </> "kit.json") manifestWithDemoVersionJson+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ demoConditions rows "claude" >>= (@?= [KitOutdated, KitChangedUpstream, KitLocallyModified]),+ testCase "status reports modified for exactly what update would skip" $+ withPreparedKitHome $ \home cache -> do+ -- Keep project scope inside the temporary HOME so the update's+ -- project-scope scan never looks at the working directory.+ let config = testConfig & #projectRoot .~ pure (home </> "project")+ _ <- assertRight =<< installItem config "demo" UserScope+ _ <- assertRight =<< installItem config "reviewer" UserScope+ BS.writeFile (userClaudeSkill home </> "SKILL.md") "my edits"+ rows <- collectStatus config cache [(UserScope, "user"), (ProjectScope, "project")]+ let toScope scopeText = if scopeText == "project" then ProjectScope else UserScope+ modifiedByStatus =+ nub+ [ (row ^. #name, toScope (row ^. #scope))+ | row <- rows,+ KitLocallyModified `elem` row ^. #conditions+ ]+ manifest <- assertRight =<< loadManifest cache+ report <- assertRight =<< reinstallPresent config cache manifest Nothing KeepLocalEdits+ sort (report ^. #skipped) @?= sort modifiedByStatus+ modifiedByStatus @?= [("demo", UserScope)] ]+ where+ userClaudeSkill home = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"+ demoConditions rows providerText =+ case filter (\row -> row ^. #name == "demo" && row ^. #providers == providerText) rows of+ [row] -> pure (row ^. #conditions)+ other -> assertFailure ("expected one " <> Text.unpack providerText <> " demo row, got " <> show other) installRoundTripTests :: TestTree installRoundTripTests =@@ -404,7 +503,7 @@ createDirectoryIfMissing True home setEnv "HOME" home flip finally (restoreHome oldHome) $ do- exitResult <- try @ExitCode (runKit config KitStatus)+ exitResult <- try @ExitCode (runKit config (KitStatus HumanOutput)) exitResult @?= Right () report <- kitStatus config (report ^. #rows) @?= []@@ -563,13 +662,403 @@ files = Nothing } +jsonTests :: TestTree+jsonTests =+ testGroup+ "JSON"+ [ testCase "kit-status document matches the golden" $+ withStatusFixture $ \home proj config -> do+ document <- statusDocument <$> kitStatus config+ golden "status.json" (normalise home proj document),+ testCase "kit-list document matches the golden" $+ withStatusFixture $ \home proj config -> do+ manifest <- assertRight =<< loadManifest (fixtureCache home)+ copies <- installedCopies config+ golden "list.json" (normalise home proj (listDocument (UpstreamStale "x") manifest copies)),+ testCase "kit-update document matches the golden" $+ withPreparedKitHome $ \home cache -> do+ let config = testConfig & #projectRoot .~ pure (home </> "project")+ _ <- assertRight =<< installItem config "demo" UserScope+ _ <- assertRight =<< installItem config "reviewer" UserScope+ BS.writeFile (home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo" </> "SKILL.md") "my edits"+ manifest <- assertRight =<< loadManifest cache+ report <- assertRight =<< reinstallPresent config cache manifest Nothing KeepLocalEdits+ golden "update.json" (updateDocument report)+ jsonKey "refresh" (updateDocument (report & #refresh .~ Just RepoPulled)) @?= Just (String "pulled"),+ testCase "every document names its format version" $+ withStatusFixture $ \home _proj config -> do+ manifest <- assertRight =<< loadManifest (fixtureCache home)+ copies <- installedCopies config+ statusDoc <- statusDocument <$> kitStatus config+ report <- assertRight =<< reinstallPresent config (fixtureCache home) manifest (Just "no-such-item") KeepLocalEdits+ let documents =+ [ ("kit-list", listDocument UpstreamReady manifest copies),+ ("kit-status", statusDoc),+ ("kit-update", updateDocument report)+ ]+ forM_ documents $ \(name, document) -> do+ jsonKey "formatVersion" document @?= Just (Aeson.toJSON kitJsonFormatVersion)+ jsonKey "document" document @?= Just (String name),+ testCase "status --json parses as one document when the cache is stale" $+ withPreparedKitHome $ \_home _cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope+ (result, out) <- captureStdout (runKitCommand testConfig (KitStatus JsonOutput))+ result @?= Right ()+ document <- decodeDocument out+ (jsonKey "upstream" document >>= jsonKey "state") @?= Just (String "stale"),+ testCase "status --json parses as one document when the repository is unreachable" $+ withFreshHome $ \_home -> do+ let config = testConfig & #repoUrl .~ "file:///nonexistent-kit"+ (result, out) <- captureStdout (runKitCommand config (KitStatus JsonOutput))+ result @?= Right ()+ document <- decodeDocument out+ (jsonKey "upstream" document >>= jsonKey "state") @?= Just (String "unavailable")+ jsonKey "items" document @?= Just (Array mempty),+ testCase "list --json writes nothing to stdout when the repository is unreachable" $+ withFreshHome $ \_home -> do+ let config = testConfig & #repoUrl .~ "file:///nonexistent-kit"+ (result, out) <- captureStdout (runKitCommand config (KitList JsonOutput))+ assertKitError "KitCloneFailed" isCloneFailed result+ out @?= "",+ testCase "list --json keeps the first-clone notice off stdout" $+ withFreshHome $ \home -> do+ let repoDir = takeDirectory home </> "kit-repo"+ createDirectoryIfMissing True (repoDir </> "skills" </> "demo")+ createDirectoryIfMissing True (repoDir </> "agents")+ BS.writeFile (repoDir </> "skills" </> "demo" </> "SKILL.md") "skill instructions\n"+ BS.writeFile (repoDir </> "agents" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"+ BS.writeFile (repoDir </> "kit.json") manifestJson+ git repoDir ["init", "--quiet"]+ git repoDir ["add", "."]+ git repoDir ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "kit"]+ let config = testConfig & #repoUrl .~ ("file://" <> Text.pack repoDir)+ (result, out) <- captureStdout (runKitCommand config (KitList JsonOutput))+ result @?= Right ()+ document <- decodeDocument out+ (jsonKey "upstream" document >>= jsonKey "state") @?= Just (String "ready")+ case jsonKey "items" document of+ Just (Array items) -> map (jsonKey "name") (toList items) @?= [Just (String "demo"), Just (String "reviewer")]+ other -> assertFailure ("expected an items array, got " <> show other),+ testCase "update --json writes nothing to stdout when the pull fails" $+ withPreparedKitHome $ \_home _cache -> do+ (result, out) <- captureStdout (runKitCommand testConfig (KitUpdate Nothing KeepLocalEdits JsonOutput))+ assertKitError "KitPullFailed" isPullFailed result+ out @?= "",+ testCase "the command and the encoder agree" $+ withStatusFixture $ \home proj config -> do+ (result, out) <- captureStdout (runKitCommand config (KitStatus JsonOutput))+ result @?= Right ()+ document <- decodeDocument out+ golden "status.json" (normalise home proj document),+ testCase "--json parses on list, status, and update only" $ do+ let parse = getParseResult . execParserPure defaultPrefs (info (kitCommandParser testConfig) mempty)+ parse ["list", "--json"] @?= Just (KitList JsonOutput)+ parse ["status", "--json"] @?= Just (KitStatus JsonOutput)+ parse ["update", "--json"] @?= Just (KitUpdate Nothing KeepLocalEdits JsonOutput)+ parse ["install", "demo", "--json"] @?= Nothing+ ]+ where+ isCloneFailed = \case+ KitCloneFailed _ _ -> True+ _ -> False+ git dir args = do+ (code, _out, err) <- readProcessWithExitCode "git" ("-C" : dir : args) ""+ assertBool ("git " <> unwords args <> " failed: " <> err) (code == ExitSuccess)+ decodeDocument out = case Aeson.eitherDecodeStrict' out of+ Right document -> pure document+ Left err -> assertFailure ("stdout is not one JSON document (" <> err <> "): " <> show out)++-- | A temporary, empty @HOME@ for the duration of the action.+withFreshHome :: (FilePath -> IO a) -> IO a+withFreshHome action =+ withSystemTempDirectory "baikai-kit-fresh" $ \tmp -> do+ oldHome <- lookupEnv "HOME"+ let home = tmp </> "home"+ createDirectoryIfMissing True home+ setEnv "HOME" home+ action home `finally` restoreHome oldHome++fixtureCache :: FilePath -> FilePath+fixtureCache home = home </> ".cache" </> "testkit" </> "kit"++-- | Seven items covering every status condition, both kinds, both scopes,+-- and both providers. The action gets @HOME@, the project root, and a+-- config whose project scope is that root.+withStatusFixture :: (FilePath -> FilePath -> KitConfig -> IO a) -> IO a+withStatusFixture action =+ withPreparedKitHome $ \home cache -> do+ let proj = takeDirectory home </> "project"+ config = testConfig & #projectRoot .~ pure proj+ skills = ["alpha", "beta", "gamma", "delta", "epsilon"]+ agents = ["reviewer", "planner"]+ claudeBase = home </> ".config" </> "testkit" </> "agents" </> ".claude"+ createDirectoryIfMissing True proj+ forM_ skills $ \n -> do+ createDirectoryIfMissing True (cache </> "skills" </> n)+ BS.writeFile (cache </> "skills" </> n </> "SKILL.md") ("the " <> Text.Encoding.encodeUtf8 (Text.pack n) <> " skill\n")+ forM_ agents $ \n ->+ BS.writeFile (cache </> "agents" </> (n <> ".md")) ("---\nname: " <> Text.Encoding.encodeUtf8 (Text.pack n) <> "\n---\nBe helpful.\n")+ BS.writeFile (cache </> "kit.json") (fixtureManifest [] [])+ forM_ ["alpha", "gamma", "epsilon", "reviewer"] $ \n ->+ void (assertRight =<< installItem config n UserScope)+ forM_ ["beta", "delta", "planner"] $ \n ->+ void (assertRight =<< installItem config n ProjectScope)+ -- alpha: the Codex copy loses its sidecar => unknown.+ removeFile (home </> ".agents" </> "skills" </> "alpha" </> ".testkit-kit.json")+ -- gamma: upstream sources change without a version bump => changed-upstream.+ BS.writeFile (cache </> "skills" </> "gamma" </> "SKILL.md") "the gamma skill, revised\n"+ -- epsilon: upstream now lists a file through a symbolic link => refused.+ let outsideDir = takeDirectory home </> "outside"+ createDirectoryIfMissing True outsideDir+ BS.writeFile (outsideDir </> "secret.txt") "top secret\n"+ createDirectoryLink outsideDir (cache </> "skills" </> "epsilon" </> "sub")+ -- reviewer: the Claude copy is edited => modified.+ BS.writeFile (claudeBase </> "agents" </> "reviewer.md") "my own reviewer\n"+ -- planner: the Claude sidecar predates the installed-file hash => edits-unknown.+ let plannerSidecar = proj </> ".testkit" </> "agents" </> ".claude" </> "agents" </> "planner.testkit-kit.json"+ sidecar <- maybe (assertFailure "expected the planner sidecar") pure =<< readSidecar plannerSidecar+ LBS.writeFile plannerSidecar (Aeson.encode (sidecar & #installedFiles .~ Nothing & #installedHash .~ Nothing))+ -- beta: version bump => outdated; delta: removed => delisted.+ BS.writeFile (cache </> "kit.json") (fixtureManifest ["beta"] ["delta"])+ action home proj config++-- | The fixture manifest: every item at 0.1.0 except those in @bumped@+-- (0.2.0), without those in @removed@; once anything is bumped, epsilon+-- also lists a file below its symlinked @sub@ directory.+fixtureManifest :: [Text] -> [Text] -> BS.ByteString+fixtureManifest bumped removed =+ Text.Encoding.encodeUtf8 $+ "{\"version\":2,\"skills\":["+ <> Text.intercalate "," [skill n | n <- ["alpha", "beta", "gamma", "delta", "epsilon"], n `notElem` removed]+ <> "],\"agents\":["+ <> Text.intercalate "," [agent n | n <- ["reviewer", "planner"], n `notElem` removed]+ <> "]}"+ where+ final = not (null bumped)+ version n = if n `elem` bumped then "0.2.0" else "0.1.0"+ files n+ | n == "epsilon" && final = "[\"SKILL.md\",\"sub/secret.txt\"]"+ | otherwise = "[\"SKILL.md\"]"+ skill n =+ "{\"name\":\""+ <> n+ <> "\",\"description\":\"The "+ <> n+ <> " skill\",\"version\":\""+ <> version n+ <> "\",\"path\":\"skills/"+ <> n+ <> "\",\"files\":"+ <> files n+ <> "}"+ agent n =+ "{\"name\":\""+ <> n+ <> "\",\"description\":\"The "+ <> n+ <> " agent\",\"version\":\""+ <> version n+ <> "\",\"path\":\"agents/"+ <> n+ <> ".md\"}"++-- | Run an action with stdout sent to a file, and return what it wrote.+-- The pause first lets tasty's reporter finish writing the test name,+-- which it does on stdout from another thread as the test starts.+captureStdout :: IO a -> IO (a, BS.ByteString)+captureStdout action =+ withSystemTempFile "baikai-kit-stdout" $ \file fileHandle -> do+ threadDelay 200000+ hFlush stdout+ saved <- hDuplicate stdout+ hDuplicateTo fileHandle stdout+ result <-+ action `finally` do+ hFlush stdout+ hDuplicateTo saved stdout+ hClose saved+ hClose fileHandle+ out <- BS.readFile file+ pure (result, out)++-- | Replace the temporary directories in every string with @$HOME@ and+-- @$PROJECT@, and any upstream detail (git's message, which names paths+-- and varies by git version) with @<detail>@.+normalise :: FilePath -> FilePath -> Value -> Value+normalise home proj = go+ where+ go = \case+ String t -> String (Text.replace (Text.pack proj) "$PROJECT" (Text.replace (Text.pack home) "$HOME" t))+ Array values -> Array (fmap go values)+ Object o -> Object (KeyMap.mapWithKey (\key value -> if key == "upstream" then upstream value else go value) o)+ other -> other+ upstream = \case+ Object o -> Object (KeyMap.mapWithKey (\key value -> if key == "detail" && value /= Null then String "<detail>" else value) o)+ other -> other++-- | Compare with @test/golden/<file>@, or write it when+-- @BAIKAI_KIT_ACCEPT_GOLDEN@ is set. Values are compared decoded, so key+-- order and whitespace are not part of the contract.+golden :: FilePath -> Value -> Assertion+golden file value = do+ let path = "test" </> "golden" </> file+ accept <- lookupEnv "BAIKAI_KIT_ACCEPT_GOLDEN"+ case accept of+ Just _ -> do+ createDirectoryIfMissing True ("test" </> "golden")+ LBS.writeFile path (Aeson.encode value <> "\n")+ Nothing -> do+ expected <- Aeson.eitherDecodeFileStrict' path+ case expected of+ Left err -> assertFailure ("cannot read golden " <> path <> ": " <> err)+ Right expectedValue ->+ assertBool+ ("golden " <> path <> " differs.\nexpected: " <> show (Aeson.encode expectedValue) <> "\nactual: " <> show (Aeson.encode value))+ (expectedValue == (value :: Value))++jsonKey :: Aeson.Key -> Value -> Maybe Value+jsonKey key = \case+ Object o -> KeyMap.lookup key o+ _ -> Nothing++commandTests :: TestTree+commandTests =+ testGroup+ "Command"+ [ testCase "install parses with and without a name" $ do+ parse ["install"] @?= Just (KitInstall Nothing UserScope)+ parse ["install", "demo", "--project"] @?= Just (KitInstall (Just "demo") ProjectScope)+ parse [] @?= Just (KitList HumanOutput),+ testCase "install help names the tool's project directory" $+ case execParserPure defaultPrefs (info (kitCommandParser testConfig <**> helper) mempty) ["install", "--help"] of+ Failure failure -> do+ let rendered = fst (renderFailure failure "kit")+ assertBool ("expected .testkit/agents in help:\n" <> rendered) (".testkit/agents" `isInfixOf` rendered)+ _ -> assertFailure "expected --help to produce help text",+ testCase "install with no name and no chooser is a KitItemNameRequired error" $ do+ result <- runKitCommand testConfig (KitInstall Nothing UserScope)+ result @?= Left KitItemNameRequired+ exitResult <- try @ExitCode (runKit testConfig (KitInstall Nothing UserScope))+ exitResult @?= Left (ExitFailure 1),+ testCase "install with no name installs what the chooser returns" $+ withPreparedKitHome $ \home _cache -> do+ seen <- newIORef []+ let chooser manifest = do+ writeIORef seen (map (view #name) (manifest ^. #skills) ++ map (view #name) (manifest ^. #agents))+ pure (Just "demo")+ config = testConfig & #chooseItem .~ Just chooser+ result <- runKitCommand config (KitInstall Nothing UserScope)+ result @?= Right ()+ assertFileExists (userClaudeDemo home </> "SKILL.md")+ readIORef seen >>= (@?= ["demo", "reviewer"]),+ testCase "a cancelled choice installs nothing and succeeds" $+ withPreparedKitHome $ \home _cache -> do+ let config = testConfig & #chooseItem .~ Just (\_ -> pure Nothing)+ result <- runKitCommand config (KitInstall Nothing UserScope)+ result @?= Right ()+ assertDirectoryMissing (userClaudeDemo home)+ exitResult <- try @ExitCode (runKit config (KitInstall Nothing UserScope))+ exitResult @?= Right ()+ assertDirectoryMissing (userClaudeDemo home),+ testCase "a chosen name the manifest lacks is KitItemNotFound" $+ withPreparedKitHome $ \_home _cache -> do+ let config = testConfig & #chooseItem .~ Just (\_ -> pure (Just "nope"))+ result <- runKitCommand config (KitInstall Nothing UserScope)+ result @?= Left (KitItemNotFound "nope")+ ]+ where+ parse = getParseResult . execParserPure defaultPrefs (info (kitCommandParser testConfig) mempty)+ userClaudeDemo home = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"++projectRootTests :: TestTree+projectRootTests =+ testGroup+ "Project root"+ [ testCase "findProjectRoot walks up from a nested directory" $+ withMarkedTree $ \root -> do+ found <- findProjectRoot [rootMarker] (root </> "a" </> "b")+ found @?= Just root,+ testCase "findProjectRoot accepts a start directory that is itself the root" $+ withMarkedTree $ \root -> do+ found <- findProjectRoot [rootMarker] root+ found @?= Just root,+ testCase "findProjectRoot returns Nothing when no marker exists" $+ withMarkedTree $ \root -> do+ found <- findProjectRoot [".testkit-no-such-marker"] (root </> "a")+ found @?= Nothing+ withCurrentDirectory (root </> "a") $ do+ resolved <- projectRootByMarkers [".testkit-no-such-marker"]+ cwd <- getCurrentDirectory+ resolved @?= cwd,+ testCase "a configured root puts project scope in one place" $+ withPreparedKitHome $ \_home _cache ->+ withProjectTree $ \proj -> do+ let config = testConfig & #projectRoot .~ pure proj+ claudeSkill = proj </> ".testkit" </> "agents" </> ".claude" </> "skills" </> "demo"+ withCurrentDirectory (proj </> "src" </> "deep") $+ void (assertRight =<< installItem config "demo" ProjectScope)+ assertFileExists (claudeSkill </> "SKILL.md")+ assertFileExists (proj </> ".agents" </> "skills" </> "demo" </> "SKILL.md")+ assertDirectoryMissing (proj </> "src" </> "deep" </> ".testkit")+ withCurrentDirectory (proj </> "docs") $ do+ assertProjectRow config+ dirs <- agentDirsForSession config+ assertBool+ ("expected the project agents dir in " <> show dirs)+ ((proj </> ".testkit" </> "agents") `elem` dirs)+ outcomes <- assertRight =<< uninstallItem config "demo" ProjectScope+ let rendered = renderUninstallReport "demo" ProjectScope outcomes+ assertBool+ ("unexpected uninstall report: " <> Text.unpack rendered)+ ("Uninstalled skill 'demo' from project scope" `Text.isPrefixOf` rendered)+ assertDirectoryMissing claudeSkill+ let markerConfig = testConfig & #projectRoot .~ projectRootByMarkers [rootMarker]+ withCurrentDirectory (proj </> "src" </> "deep") $+ void (assertRight =<< installItem markerConfig "demo" ProjectScope)+ assertFileExists (claudeSkill </> "SKILL.md")+ withCurrentDirectory (proj </> "docs") $ assertProjectRow markerConfig,+ testCase "without a resolver, project scope is the current directory" $+ withPreparedKitHome $ \_home _cache ->+ withProjectTree $ \proj ->+ withCurrentDirectory (proj </> "src" </> "deep") $ do+ _ <- assertRight =<< installItem testConfig "demo" ProjectScope+ cwd <- getCurrentDirectory+ assertFileExists (cwd </> ".testkit" </> "agents" </> ".claude" </> "skills" </> "demo" </> "SKILL.md")+ assertDirectoryMissing (proj </> ".testkit")+ ]+ where+ assertProjectRow config = do+ report <- kitStatus config+ let projectRows = filter (\row -> row ^. #name == "demo" && row ^. #scope == "project") (report ^. #rows)+ assertBool "expected a project-scope status row for demo" (not (null projectRows))++-- | A marker no real directory above the system temporary directory can+-- hold, so a walk to the filesystem root cannot find someone's @.git@.+rootMarker :: FilePath+rootMarker = ".testkit-root-marker"++-- | @root/.testkit-root-marker@ and @root/a/b@, with @root@ canonical so+-- it compares equal to paths the resolver builds.+withMarkedTree :: (FilePath -> IO a) -> IO a+withMarkedTree action =+ withSystemTempDirectory "baikai-kit-root" $ \tmp -> do+ root <- (</> "root") <$> canonicalizePath tmp+ createDirectoryIfMissing True (root </> "a" </> "b")+ BS.writeFile (root </> rootMarker) ""+ action root++-- | A project with a root marker, @src/deep@, and @docs@.+withProjectTree :: (FilePath -> IO a) -> IO a+withProjectTree action =+ withSystemTempDirectory "baikai-kit-project" $ \tmp -> do+ proj <- (</> "proj") <$> canonicalizePath tmp+ createDirectoryIfMissing True (proj </> "src" </> "deep")+ createDirectoryIfMissing True (proj </> "docs")+ BS.writeFile (proj </> rootMarker) ""+ action proj+ testConfig :: KitConfig-testConfig =- KitConfig- { toolName = "testkit",- repoUrl = "file:///not-used",- providers = [InteractiveClaude, InteractiveCodex]- }+testConfig = kitConfig "testkit" "file:///not-used" [InteractiveClaude, InteractiveCodex] withPreparedKitHome :: (FilePath -> FilePath -> IO a) -> IO a withPreparedKitHome action =