baikai-kit-0.3.0.0: src/Baikai/Kit/Status.hs
module Baikai.Kit.Status
( KitCondition (..),
InstalledCopy (..),
StatusReport (..),
StatusRow (..),
UpstreamAvailability (..),
classify,
collectStatus,
installedCopies,
kitStatus,
conditionLabel,
renderConditions,
renderStatusTable,
)
where
import Baikai.AgentAssets (AgentAssetProvider, agentTargetPath, skillTargetPath)
import Baikai.Interactive (InteractiveScope (InteractiveProjectScope))
import Baikai.Kit.Config (KitConfig, KitScope (..), providerAgentsBase, providerLabel, sidecarFileName)
import Baikai.Kit.Error (KitError (..))
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, isNothing)
import Data.Text qualified as Text
import System.Directory (doesDirectoryExist, listDirectory)
import System.FilePath (takeDirectory, (</>))
-- | 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
= -- | The cache was refreshed and its manifest read.
UpstreamReady
| -- | The cache could not be refreshed; the rows compare against the
-- copy already on disk. The 'Text' is git's output.
UpstreamStale !Text
| -- | There is no usable cache: the rows say what is installed and
-- nothing about how it compares.
UpstreamUnavailable !KitError
deriving stock (Eq, Show)
-- | What @kit status@ found, for the caller to render.
data StatusReport = StatusReport
{ upstream :: !UpstreamAvailability,
rows :: ![StatusRow]
}
deriving stock (Eq, Generic, Show)
data StatusRow = StatusRow
{ name :: !Text,
kind :: !Text,
scope :: !Text,
providers :: !Text,
installedVersion :: !(Maybe Text),
latestVersion :: !(Maybe Text),
-- | Sorted and duplicate-free; empty means up to date.
conditions :: ![KitCondition]
}
deriving stock (Eq, Generic, Show)
-- | 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"
KitOutdated -> "outdated"
KitChangedUpstream -> "changed-upstream"
KitLocallyModified -> "modified"
KitLocalEditsUnknown -> "edits-unknown"
-- | @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
changed = case mUpstreamHash of
Just up -> up /= sm ^. #hash
Nothing -> False
in [KitOutdated | outdated] ++ [KitChangedUpstream | changed]
-- | Collect the status of everything installed. Needs no network: a kit
-- repository that cannot be reached is reported as
-- 'UpstreamUnavailable' and the installed rows are still returned.
kitStatus :: KitConfig -> IO StatusReport
kitStatus config = do
repo <- ensureKitRepo config
case repo of
Left err -> report (UpstreamUnavailable err) ""
Right resolved -> do
let availability = case resolved ^. #refresh of
RepoStale err -> UpstreamStale err
RepoCloned -> UpstreamReady
RepoPulled -> UpstreamReady
manifest <- loadManifestMaybe (resolved ^. #dir)
case manifest of
Left err -> report (UpstreamUnavailable err) ""
Right Nothing -> report availability ""
Right (Just _) -> report availability (resolved ^. #dir)
where
report upstream cacheDir = do
rows <- collectStatus config cacheDir [(UserScope, "user"), (ProjectScope, "project")]
pure StatusReport {upstream, rows}
collectStatus :: KitConfig -> FilePath -> [(KitScope, Text)] -> IO [StatusRow]
collectStatus config cacheDir scopes = do
-- A manifest that cannot be read is treated here as no manifest; the
-- report as a whole says so through 'UpstreamUnavailable'.
mManifest <- either (const Nothing) id <$> 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))
upstream <- upstreamHash cacheDir mItem
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',
kind = maybe (kindLabel scannedKind) itemKind mItem,
scope = scopeText,
providers = providerLabel provider,
installedVersion = mSidecar >>= (^. #version),
latestVersion = mItem >>= itemVersion,
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,
-- no manifest entry, or an upstream source that is simply gone. A
-- 'Left' means the upstream listing is one this installer refuses to
-- read — a symbolic link, an escaping path, an unsafe manifest string —
-- which 'collectStatus' shows as 'KitUpstreamRefused'.
upstreamHash :: FilePath -> Maybe KitItem -> IO (Either KitError (Maybe Text))
upstreamHash "" _ = pure (Right Nothing)
upstreamHash _ Nothing = pure (Right Nothing)
upstreamHash cacheDir (Just item) =
case itemSources item of
Left err -> pure (Left err)
Right sources -> do
result <- computeKitHash cacheDir (sources ^. #base) (sources ^. #files)
pure $ case result of
Left (KitSourceMissing _) -> Right Nothing
Left err -> Left err
Right h -> Right (Just h)
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 []
-- | The table @kit status@ prints, without a trailing newline.
renderStatusTable :: [StatusRow] -> Text
renderStatusTable [] = "No kit items installed."
renderStatusTable rows = Text.intercalate "\n" (hdr : map printRow displayRows)
where
displayRows = aggregateStatusRows rows
nameW = colWidth "NAME" (^. #name)
kindW = colWidth "TYPE" (^. #kind)
scopeW = colWidth "SCOPE" (^. #scope)
providersW = colWidth "PROVIDERS" (^. #providers)
instW = colWidth "INSTALLED" (renderMVer . view #installedVersion)
latW = colWidth "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"
colWidth colTitle f =
maximum (Text.length colTitle : map (Text.length . f) displayRows)
renderMVer = fromMaybe "-"
printRow row =
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))
<> renderConditions (row ^. #conditions)
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 ^. #conditions
)
sameKey a b = rowKey a == rowKey b
summarize groupRows@(firstRow : _) =
firstRow & #providers .~ Text.intercalate "," (sort (nub (map (^. #providers) groupRows)))
summarize [] = error "aggregateStatusRows: empty group"
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`)