packages feed

baikai-kit-0.3.0.0: src/Baikai/Kit/Command.hs

-- | The optparse-applicative adapter a consuming tool wires up as its
--   @kit@ subcommand.
--
--   'runKit' is the only function in @baikai-kit@ that exits the process;
--   see @docs/adr/0013-library-code-never-calls-exitfailure.md@.
module Baikai.Kit.Command
  ( KitCommand (..),
    OutputFormat (..),
    kitCommandParser,
    runKit,
    runKitCommand,
  )
where

import Baikai.Kit.Config (KitConfig, KitScope (..), scopeLabel)
import Baikai.Kit.Error (KitError (..), renderKitError)
import Baikai.Kit.Install
  ( OverwritePolicy (..),
    UpdateReport,
    installFrom,
    loadManifest,
    renderAvailable,
    renderUninstallReport,
    uninstallItem,
    updateKit,
  )
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 (..), 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 (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 !OutputFormat
  | -- | 'Nothing' asks the configured 'Baikai.Kit.Config.chooseItem'.
    KitInstall !(Maybe Text) !KitScope
  | KitUpdate !(Maybe Text) !OverwritePolicy !OutputFormat
  | KitUninstall !Text !KitScope
  | KitStatus !OutputFormat
  deriving stock (Eq, Show)

-- | 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 (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 config) (progDesc "Uninstall a skill or subagent"))
        <> command "status" (info (KitStatus <$> formatParser) (progDesc "Show installed skills and subagents"))
    )
    <|> 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 HumanOutput -> withRepo HumanOutput $ \repo ->
    loadManifest (repo ^. #dir) `thenE` \manifest ->
      printed (renderAvailable manifest)
  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 ->
      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 format -> do
    report <- kitStatus config
    noteUpstream report
    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.
    -- 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)
        Right resolved -> do
          case resolved ^. #refresh of
            RepoStale err ->
              Text.IO.hPutStrLn stderr $
                "Warning: kit repository could not be refreshed (" <> Text.strip err <> "); using the cached copy."
            RepoCloned ->
              Text.IO.hPutStrLn (noticeHandle format) ("Fetched " <> (config ^. #toolName) <> "-kit.")
            RepoPulled -> pure ()
          next resolved

    noteUpstream :: StatusReport -> IO ()
    noteUpstream report = case report ^. #upstream of
      UpstreamReady -> pure ()
      UpstreamStale err ->
        Text.IO.hPutStrLn stderr $
          "Warning: kit repository could not be refreshed ("
            <> Text.strip err
            <> "); comparing against the cached copy."
      UpstreamUnavailable err ->
        Text.IO.hPutStrLn stderr $
          "Note: kit repository unavailable ("
            <> Text.strip (renderKitError err)
            <> "); showing installed items without upstream comparison."

    thenE :: IO (Either KitError a) -> (a -> IO (Either KitError b)) -> IO (Either KitError b)
    thenE step next = step >>= either (pure . Left) next

    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.
runKit :: KitConfig -> KitCommand -> IO ()
runKit config kitCommand = do
  result <- runKitCommand config kitCommand
  case result of
    Right () -> pure ()
    Left err -> do
      Text.IO.hPutStrLn stderr ("Error: " <> renderKitError err)
      exitWith (ExitFailure 1)

renderUpdateReport :: UpdateReport -> Text
renderUpdateReport report =
  Text.intercalate "\n" (headline ++ updatedLines ++ skippedLines ++ [summary] ++ skipSummary)
  where
    headline = case report ^. #refresh of
      Nothing -> []
      Just RepoCloned -> ["Kit repository cloned."]
      Just RepoPulled -> ["Kit repository updated."]
      Just (RepoStale _) -> ["Kit repository updated."]
    updatedLines =
      [ "Updated '" <> n <> "' (" <> scopeLabel scope <> ")"
      | (n, scope) <- report ^. #updated
      ]
    -- A skip is not a failure: the command still exits 0, and the line
    -- says exactly which invocation would overwrite the edits.
    skippedLines =
      [ "Skipped '"
          <> n
          <> "' ("
          <> scopeLabel scope
          <> "): installed files were modified locally; run 'kit update "
          <> n
          <> " --force' to overwrite."
      | (n, scope) <- report ^. #skipped
      ]
    summary = "Updated " <> Text.pack (show (length (report ^. #updated))) <> " item(s)."
    skipSummary
      | null (report ^. #skipped) = []
      | otherwise = ["Skipped " <> Text.pack (show (length (report ^. #skipped))) <> " item(s)."]

installParser :: KitConfig -> Parser KitCommand
installParser config =
  KitInstall
    <$> 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 =
  KitUpdate
    <$> optional (strArgument (metavar "NAME" <> help "Name of a specific item to update (default: all)"))
    <*> flag
      KeepLocalEdits
      OverwriteLocalEdits
      (long "force" <> help "Reinstall items even if their installed files were modified locally")
    <*> formatParser

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 (" <> 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"