seihou-cli-0.4.0.0: src-exe/Seihou/CLI/Install.hs
module Seihou.CLI.Install
( handleInstall,
installModuleDir,
cloneRepo,
copyDirectoryRecursive,
)
where
import Control.Applicative ((<|>))
import Control.Monad (when)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Seihou.CLI.BrowseFormat (kindLabel)
import Seihou.CLI.Commands (InstallOpts (..))
import Seihou.CLI.InstallHistory (HistoryEntry (..), InstallHistory (..), readHistory, recordUrl)
import Seihou.CLI.InstallShared (cloneRepo, copyDirectoryRecursive, installModuleDir)
import Seihou.CLI.Registry.Sync (checkRegistryVersionDrift)
import Seihou.CLI.Shared (logIO)
import Seihou.Core.AgentPrompt (validateAgentPrompt)
import Seihou.Core.Blueprint (validateBlueprint)
import Seihou.Core.Install (parseModuleName)
import Seihou.Core.Module (validateModule)
import Seihou.Core.Registry (EntryKind (..), Registry (..), RegistryEntry (..), RepoContents (..), discoverRepoContents, validateRegistry)
import Seihou.Core.Types
import Seihou.Dhall.Eval (evalAgentPromptFromFile, evalBlueprintFromFile, evalModuleFromFile, evalRegistryFromFile)
import Seihou.Effect.Fzf (selectOne)
import Seihou.Effect.FzfInterp (runFzfIO)
import Seihou.Effect.Logger (logError, logWarn)
import Seihou.Fzf (Candidate (..), FzfConfig, FzfResult (..), detectFzfConfig, isFzfUsable, withAnsi, withHeader, withHeight, withNoSort, withPrompt)
import Seihou.Fzf qualified
import Seihou.Prelude
import System.Directory (doesFileExist)
import System.Exit (exitFailure)
import System.IO (hFlush, stdout)
import System.IO.Temp (withSystemTempDirectory)
handleInstall :: InstallOpts -> IO ()
handleInstall iopts = do
source <- resolveSource iopts.installSource
TIO.putStrLn $ "Installing from " <> source <> "..."
-- Clone into a temporary directory
withSystemTempDirectory "seihou-install" $ \tmpDir -> do
let repoName = parseModuleName source
cloneDir = tmpDir </> repoName
cloneRes <- cloneRepo source cloneDir
case cloneRes of
Left err -> do
logIO LogNormal $ do
logError err
exitFailure
Right () -> TIO.putStrLn " Cloned repository"
-- Determine what the repo contains
contents <- discoverRepoContents evalRegistryFromFile cloneDir
case contents of
EmptyRepo -> do
logIO LogNormal (logError "repository contains neither seihou-registry.dhall nor a supported runnable Dhall file.")
exitFailure
SingleModule rootDir -> do
when (not (null iopts.installModules) || iopts.installAll) $
logIO LogNormal (logWarn "--module and --all flags are ignored for single-module repositories.")
installSingleModule iopts rootDir source Nothing
SingleRecipe rootDir -> do
when (not (null iopts.installModules) || iopts.installAll) $
logIO LogNormal (logWarn "--module and --all flags are ignored for single-recipe repositories.")
installSingleRecipe iopts rootDir source
SingleBlueprint rootDir -> do
when (not (null iopts.installModules) || iopts.installAll) $
logIO LogNormal (logWarn "--module and --all flags are ignored for single-blueprint repositories.")
installSingleBlueprint iopts rootDir source
SinglePrompt rootDir -> do
when (not (null iopts.installModules) || iopts.installAll) $
logIO LogNormal (logWarn "--module and --all flags are ignored for single-prompt repositories.")
installSinglePrompt iopts rootDir source
MultiModule registry -> do
regErrors <- validateRegistry cloneDir registry
if not (null regErrors)
then do
logIO LogNormal $ do
logError "registry has validation errors:"
mapM_ (\e -> logError $ " - " <> e) regErrors
exitFailure
else do
driftWarnings <- checkRegistryVersionDrift cloneDir registry
logIO LogNormal (mapM_ logWarn driftWarnings)
installFromRegistry iopts cloneDir registry source
-- Record URL in history for future recall (only reached on success)
recordUrl source
-- | Resolve the install source: use the explicit URL if given, otherwise pick from history.
resolveSource :: Maybe Text -> IO Text
resolveSource (Just url) = pure url
resolveSource Nothing = do
history <- readHistory
case history.entries of
[] -> do
TIO.putStrLn "No URL specified and no install history found."
TIO.putStrLn "Usage: seihou install <git-url>"
exitFailure
entries -> do
fzfCfg <- detectFzfConfig
if isFzfUsable fzfCfg
then fzfUrlSelection fzfCfg entries
else promptUrlSelection entries
-- | FZF selection of a URL from history.
fzfUrlSelection :: FzfConfig -> [HistoryEntry] -> IO Text
fzfUrlSelection fzfCfg entries = do
let candidates =
[ Candidate
{ candidateDisplay = entry.url,
candidateValue = entry.url
}
| entry <- entries
]
opts = withPrompt "install> " <> withHeader "Select a previously used source:" <> withHeight "40%" <> withAnsi <> withNoSort
result <- runEff $ runFzfIO fzfCfg $ selectOne opts candidates
case result of
FzfSelected url -> pure url
FzfCancelled -> do
TIO.putStrLn "Cancelled."
exitFailure
FzfNoMatch -> do
TIO.putStrLn "No match."
exitFailure
FzfError err -> do
TIO.putStrLn $ "fzf error: " <> err <> ", falling back to prompt"
promptUrlSelection entries
-- | Numbered prompt fallback for URL selection.
promptUrlSelection :: [HistoryEntry] -> IO Text
promptUrlSelection entries = do
TIO.putStrLn ""
TIO.putStrLn "Previously used sources:"
let numbered = zip [1 :: Int ..] entries
mapM_
(\(i, entry) -> TIO.putStrLn $ " " <> T.pack (show i) <> ") " <> entry.url)
numbered
TIO.putStrLn ""
TIO.putStr "Select a source (number): "
hFlush stdout
input <- TIO.getLine
case readMaybe (T.unpack (T.strip input)) of
Just n
| n >= 1 && n <= length entries ->
pure (entries !! (n - 1)).url
_ -> do
TIO.putStrLn "Invalid selection."
exitFailure
-- | Install a single-module repo (legacy behavior).
installSingleModule :: InstallOpts -> FilePath -> Text -> Maybe Text -> IO ()
installSingleModule iopts rootDir source registryName = do
let name = case iopts.installName of
Just n -> T.unpack n
Nothing -> parseModuleName source
let dhallFile = rootDir </> "module.dhall"
decoded <- evalModuleFromFile dhallFile
modul <- case decoded of
Left err -> do
logIO LogNormal $ do
logError "repository is not a valid seihou module."
logError $ " " <> T.pack (show err)
exitFailure
Right m -> pure m
result <- validateModule rootDir modul
case result of
Left (ValidationError _ errors) -> do
logIO LogNormal $ do
logError "module has validation errors:"
mapM_ (\e -> logError $ " - " <> e) errors
exitFailure
Left err -> do
logIO LogNormal (logError $ T.pack (show err))
exitFailure
Right _ -> pure ()
TIO.putStrLn " Validated module definition"
installModuleDir rootDir name source registryName modul.version []
TIO.putStrLn ""
TIO.putStrLn $ "Module available as: " <> T.pack name
-- | Install a single-recipe repo.
installSingleRecipe :: InstallOpts -> FilePath -> Text -> IO ()
installSingleRecipe iopts rootDir source = do
let name = case iopts.installName of
Just n -> T.unpack n
Nothing -> parseModuleName source
-- Validate recipe.dhall exists (discoverRepoContents already confirmed it)
TIO.putStrLn " Validated recipe definition"
installModuleDir rootDir name source Nothing Nothing []
TIO.putStrLn ""
TIO.putStrLn $ "Recipe available as: " <> T.pack name
-- | Install a single-blueprint repo.
installSingleBlueprint :: InstallOpts -> FilePath -> Text -> IO ()
installSingleBlueprint iopts rootDir source = do
let name = case iopts.installName of
Just n -> T.unpack n
Nothing -> parseModuleName source
let dhallFile = rootDir </> "blueprint.dhall"
decoded <- evalBlueprintFromFile dhallFile
bp <- case decoded of
Left err -> do
logIO LogNormal $ do
logError "repository is not a valid seihou blueprint."
logError $ " " <> T.pack (show err)
exitFailure
Right b -> pure b
result <- validateBlueprint rootDir bp
case result of
Left (ValidationError _ errors) -> do
logIO LogNormal $ do
logError "blueprint has validation errors:"
mapM_ (\e -> logError $ " - " <> e) errors
exitFailure
Left err -> do
logIO LogNormal (logError $ T.pack (show err))
exitFailure
Right _ -> pure ()
TIO.putStrLn " Validated blueprint definition"
let bpVersion = case bp of Blueprint _ v _ _ _ _ _ _ _ _ -> v
installModuleDir rootDir name source Nothing bpVersion []
TIO.putStrLn ""
TIO.putStrLn $ "Blueprint available as: " <> T.pack name
-- | Install a single-prompt repo.
installSinglePrompt :: InstallOpts -> FilePath -> Text -> IO ()
installSinglePrompt iopts rootDir source = do
let name = case iopts.installName of
Just n -> T.unpack n
Nothing -> parseModuleName source
let dhallFile = rootDir </> "prompt.dhall"
decoded <- evalAgentPromptFromFile dhallFile
prompt <- case decoded of
Left err -> do
logIO LogNormal $ do
logError "repository is not a valid seihou prompt."
logError $ " " <> T.pack (show err)
exitFailure
Right p -> pure p
result <- validateAgentPrompt rootDir prompt
case result of
Left (ValidationError _ errors) -> do
logIO LogNormal $ do
logError "prompt has validation errors:"
mapM_ (\e -> logError $ " - " <> e) errors
exitFailure
Left err -> do
logIO LogNormal (logError $ T.pack (show err))
exitFailure
Right _ -> pure ()
TIO.putStrLn " Validated prompt definition"
installModuleDir rootDir name source Nothing prompt.version []
TIO.putStrLn ""
TIO.putStrLn $ "Prompt available as: " <> T.pack name
-- | Install from a multi-module registry.
installFromRegistry :: InstallOpts -> FilePath -> Registry -> Text -> IO ()
installFromRegistry iopts cloneDir registry source = do
selected <- selectModules iopts registry
if null selected
then TIO.putStrLn "No entries selected."
else do
results <- mapM (installRegistryEntry cloneDir source registry.repoName) selected
let succeeded = length (filter id results)
failed = length results - succeeded
TIO.putStrLn ""
TIO.putStrLn $
T.pack (show succeeded)
<> " "
<> (if succeeded == 1 then "entry" else "entries")
<> " installed"
<> (if failed > 0 then ", " <> T.pack (show failed) <> " failed" else "")
<> "."
-- | All registry entries (modules, recipes, blueprints, prompts) in display order.
-- Used wherever installation must treat all four kinds uniformly.
allEntries :: Registry -> [RegistryEntry]
allEntries registry = registry.modules ++ registry.recipes ++ registry.blueprints ++ registry.prompts
-- | Pair each registry entry with its 'EntryKind' tag, preserving the
-- module → recipe → blueprint → prompt display order. Used by the install picker
-- to render per-kind labels and by callers that need to discriminate
-- after selection.
labelledEntries :: Registry -> [(EntryKind, RegistryEntry)]
labelledEntries registry =
map ((,) ModuleEntry) registry.modules
++ map ((,) RecipeEntry) registry.recipes
++ map ((,) BlueprintEntry) registry.blueprints
++ map ((,) PromptEntry) registry.prompts
-- | Select which modules to install from a registry.
selectModules :: InstallOpts -> Registry -> IO [RegistryEntry]
selectModules iopts registry
| iopts.installAll = pure (allEntries registry)
| not (null iopts.installModules) = do
let entries = allEntries registry
findEntry name = filter (\e -> e.name.unModuleName == name) entries
(found, missing) =
foldr
( \name (f, m) -> case findEntry name of
(e : _) -> (e : f, m)
[] -> (f, name : m)
)
([], [])
iopts.installModules
if not (null missing)
then do
logIO LogNormal $ do
logError "the following entries were not found in the registry:"
mapM_ (\n -> logError $ " - " <> n) missing
exitFailure
else pure found
| otherwise = do
fzfCfg <- detectFzfConfig
if isFzfUsable fzfCfg
then fzfModuleSelection fzfCfg registry
else promptModuleSelection registry
-- | Interactive module selection via fzf.
fzfModuleSelection :: Seihou.Fzf.FzfConfig -> Registry -> IO [RegistryEntry]
fzfModuleSelection fzfCfg registry = do
let entries = labelledEntries registry
candidates =
[ Candidate
{ candidateDisplay =
kindLabel kind
<> " "
<> entry.name.unModuleName
<> maybe "" (\d -> " " <> d) entry.description
<> if null entry.tags then "" else " [" <> T.intercalate ", " entry.tags <> "]",
candidateValue = entry
}
| (kind, entry) <- entries
]
opts = withPrompt "entry> " <> withHeight "40%" <> withAnsi <> withNoSort
result <- runEff $ runFzfIO fzfCfg $ selectOne opts candidates
case result of
FzfSelected entry -> pure [entry]
FzfCancelled -> pure []
FzfNoMatch -> pure []
FzfError err -> do
TIO.putStrLn $ "fzf error: " <> err
promptModuleSelection registry
-- | Interactive module selection via numbered list.
promptModuleSelection :: Registry -> IO [RegistryEntry]
promptModuleSelection registry = do
TIO.putStrLn ""
TIO.putStrLn $ registry.repoName
case registry.repoDescription of
Just desc -> TIO.putStrLn $ " " <> desc
Nothing -> pure ()
TIO.putStrLn ""
TIO.putStrLn "Available modules, recipes, blueprints, and prompts:"
let entries = zip [1 :: Int ..] (labelledEntries registry)
mapM_
( \(i, (kind, entry)) ->
TIO.putStrLn $
" "
<> T.pack (show i)
<> ") "
<> kindLabel kind
<> " "
<> entry.name.unModuleName
<> maybe "" (\d -> " - " <> d) entry.description
)
entries
TIO.putStrLn ""
TIO.putStr "Select entries (comma-separated numbers, or 'all'): "
hFlush stdout
input <- TIO.getLine
let trimmed = T.strip input
let entryList = allEntries registry
if T.toLower trimmed == "all"
then pure entryList
else do
let nums = map (readMaybe . T.unpack . T.strip) (T.splitOn "," trimmed)
if any (== Nothing) nums
then do
TIO.putStrLn "Invalid input. Please enter numbers separated by commas."
pure []
else do
let indices = map (\(Just n) -> n) nums
maxIdx = length entryList
valid = all (\n -> n >= 1 && n <= maxIdx) indices
if not valid
then do
TIO.putStrLn $ "Invalid selection. Please enter numbers between 1 and " <> T.pack (show maxIdx) <> "."
pure []
else pure [entryList !! (n - 1) | n <- indices]
-- | Install a single registry entry (module, recipe, blueprint, or prompt).
installRegistryEntry :: FilePath -> Text -> Text -> RegistryEntry -> IO Bool
installRegistryEntry cloneDir source repoName entry = do
let entryDir = cloneDir </> entry.path
name = T.unpack entry.name.unModuleName
moduleDhall = entryDir </> "module.dhall"
recipeDhall = entryDir </> "recipe.dhall"
blueprintDhall = entryDir </> "blueprint.dhall"
promptDhall = entryDir </> "prompt.dhall"
TIO.putStrLn $ " Installing " <> entry.name.unModuleName <> "..."
hasModule <- doesFileExist moduleDhall
if hasModule
then do
decoded <- evalModuleFromFile moduleDhall
case decoded of
Left err -> do
logIO LogNormal $ do
logError $ " failed to load " <> entry.name.unModuleName <> ": " <> T.pack (show err)
pure False
Right modul -> do
result <- validateModule entryDir modul
case result of
Left (ValidationError _ errors) -> do
logIO LogNormal $ do
logError $ " " <> entry.name.unModuleName <> " has validation errors:"
mapM_ (\e -> logError $ " - " <> e) errors
pure False
Left err -> do
logIO LogNormal (logError $ " " <> T.pack (show err))
pure False
Right _ -> do
let ver = entry.version <|> modul.version
installModuleDir entryDir name source (Just repoName) ver entry.tags
TIO.putStrLn $ " Installed as: " <> T.pack name
pure True
else do
hasRecipe <- doesFileExist recipeDhall
if hasRecipe
then do
installModuleDir entryDir name source (Just repoName) entry.version entry.tags
TIO.putStrLn $ " Installed recipe as: " <> T.pack name
pure True
else do
hasBlueprint <- doesFileExist blueprintDhall
if hasBlueprint
then do
decoded <- evalBlueprintFromFile blueprintDhall
case decoded of
Left err -> do
logIO LogNormal $ do
logError $ " failed to load " <> entry.name.unModuleName <> ": " <> T.pack (show err)
pure False
Right bp -> do
let bpVersion = case bp of Blueprint _ v _ _ _ _ _ _ _ _ -> v
ver = entry.version <|> bpVersion
installModuleDir entryDir name source (Just repoName) ver entry.tags
TIO.putStrLn $ " Installed blueprint as: " <> T.pack name
pure True
else do
hasPrompt <- doesFileExist promptDhall
if hasPrompt
then do
decoded <- evalAgentPromptFromFile promptDhall
case decoded of
Left err -> do
logIO LogNormal $ do
logError $ " failed to load " <> entry.name.unModuleName <> ": " <> T.pack (show err)
pure False
Right prompt -> do
result <- validateAgentPrompt entryDir prompt
case result of
Left (ValidationError _ errors) -> do
logIO LogNormal $ do
logError $ " " <> entry.name.unModuleName <> " has validation errors:"
mapM_ (\e -> logError $ " - " <> e) errors
pure False
Left err -> do
logIO LogNormal (logError $ " " <> T.pack (show err))
pure False
Right _ -> do
let ver = entry.version <|> prompt.version
installModuleDir entryDir name source (Just repoName) ver entry.tags
TIO.putStrLn $ " Installed prompt as: " <> T.pack name
pure True
else do
logIO LogNormal $ do
logError $ " entry '" <> entry.name.unModuleName <> "' has no supported runnable Dhall file at " <> T.pack entry.path
pure False
readMaybe :: String -> Maybe Int
readMaybe s = case reads s of
[(n, "")] -> Just n
_ -> Nothing