packages feed

seihou-cli-0.4.0.0: src/Seihou/CLI/AgentLaunch.hs

module Seihou.CLI.AgentLaunch
  ( AgentContext (..),
    BaselineStatus (..),
    gatherAgentContext,
    defaultAllowedTools,
    setupAllowedTools,
    bootstrapAllowedTools,
    substitute,
    formatSeihouProjectState,
    formatManifestState,
    formatModuleDhallState,
    formatLocalModules,
    formatAvailableModules,
    formatBlueprintIdentity,
    formatBaselineStatus,
    formatReferenceFiles,
    formatReferenceFilesDir,
    resolveBlueprintTools,
  )
where

import Data.List (nub)
import Data.Maybe (fromMaybe)
import Data.Text qualified as T
import Seihou.Core.Module (DiscoveredModule (..), ModuleSource (..), defaultSearchPaths, discoverAllModules)
import Seihou.Core.Types
import Seihou.Prelude
import System.Directory (doesDirectoryExist, doesFileExist, getCurrentDirectory)

-- | Dynamic context gathered from the current directory, shared across agent commands.
data AgentContext = AgentContext
  { cwd :: Text,
    seihouInitialized :: Bool,
    hasManifest :: Bool,
    localModuleDhall :: Bool,
    localModules :: [Text],
    -- | (name, description, source)
    availableModules :: [(Text, Text, Text)]
  }

gatherAgentContext :: IO AgentContext
gatherAgentContext = do
  cwd <- T.pack <$> getCurrentDirectory
  seihouInitialized <- doesDirectoryExist (T.unpack cwd </> ".seihou")
  hasManifest <- doesFileExist (T.unpack cwd </> ".seihou" </> "manifest.json")
  localModuleDhall <- doesFileExist (T.unpack cwd </> "module.dhall")

  localMods <- findLocalModuleDirs (T.unpack cwd)

  searchPaths <- defaultSearchPaths
  discovered <- discoverAllModules searchPaths
  let available = concatMap toModuleInfo discovered

  pure
    AgentContext
      { cwd = cwd,
        seihouInitialized = seihouInitialized,
        hasManifest = hasManifest,
        localModuleDhall = localModuleDhall,
        localModules = localMods,
        availableModules = available
      }

-- | Default allowed tools for agent commands (assist, bootstrap).
defaultAllowedTools :: [String]
defaultAllowedTools =
  [ "Bash(seihou *)",
    "Bash(git *)",
    "Bash(ls *)",
    "Bash(mkdir *)",
    "Bash(cat *)",
    "Bash(pwd)",
    "Read",
    "Write",
    "Edit",
    "Glob",
    "Grep",
    "EnterWorktree",
    "ExitWorktree"
  ]

-- | Allowed tools for the setup command — grants full git and seihou access
-- since setup needs to init repos, stage files, commit, and run any seihou command.
setupAllowedTools :: [String]
setupAllowedTools =
  [ "Bash(seihou *)",
    "Bash(git *)",
    "Bash(ls *)",
    "Bash(mkdir *)",
    "Bash(cat *)",
    "Bash(pwd)",
    "Read",
    "Write",
    "Edit",
    "Glob",
    "Grep",
    "EnterWorktree",
    "ExitWorktree"
  ]

-- | Allowed tools for the bootstrap command — grants full git access, temp directories,
-- and common shell utilities so the agent can scaffold, test, and commit without prompting.
bootstrapAllowedTools :: [String]
bootstrapAllowedTools =
  [ "Bash(seihou *)",
    "Bash(git *)",
    "Bash(ls *)",
    "Bash(mkdir *)",
    "Bash(cat *)",
    "Bash(pwd)",
    "Bash(mktemp *)",
    "Bash(cp *)",
    "Bash(rm *)",
    "Bash(mv *)",
    "Bash(touch *)",
    "Bash(tree *)",
    "Bash(find *)",
    "Bash(wc *)",
    "Bash(diff *)",
    "Bash(head *)",
    "Bash(tail *)",
    "Bash(echo *)",
    "Bash(chmod *)",
    "Read",
    "Write",
    "Edit",
    "Glob",
    "Grep",
    "EnterWorktree",
    "ExitWorktree"
  ]

-- | Simple {{key}} substitution. Replaces each {{key}} with the corresponding value.
substitute :: [(Text, Text)] -> Text -> Text
substitute vars template = foldl' replaceOne template vars
  where
    replaceOne t (key, val) = T.replace ("{{" <> key <> "}}") val t

-- Shared context formatters

formatSeihouProjectState :: AgentContext -> Text
formatSeihouProjectState ctx
  | ctx.seihouInitialized = "Seihou project: .seihou/ directory exists (this is a seihou-managed project)"
  | otherwise = "Seihou project: No .seihou/ directory (not yet a seihou project in this directory)"

formatManifestState :: AgentContext -> Text
formatManifestState ctx
  | ctx.hasManifest = "Manifest: .seihou/manifest.json exists (modules have been applied here)"
  | otherwise = "Manifest: No manifest (no modules applied yet)"

formatModuleDhallState :: AgentContext -> Text
formatModuleDhallState ctx
  | ctx.localModuleDhall = "Module in cwd: module.dhall found in current directory (user is authoring a module here)"
  | otherwise = ""

formatLocalModules :: AgentContext -> Text
formatLocalModules ctx
  | null ctx.localModules = ""
  | otherwise = T.intercalate "\n" $ "Local modules:" : map ("  - " <>) ctx.localModules

formatAvailableModules :: AgentContext -> Text
formatAvailableModules ctx
  | null ctx.availableModules = "Available modules: None discovered"
  | otherwise =
      T.intercalate "\n" $
        "Available modules across search paths:"
          : map formatMod ctx.availableModules
  where
    formatMod (name, desc, src) = "  - " <> name <> " — " <> desc <> " (" <> src <> ")"

-- Internal helpers

findLocalModuleDirs :: FilePath -> IO [Text]
findLocalModuleDirs dir = do
  let seihouModsDir = dir </> ".seihou" </> "modules"
  hasSeihouMods <- doesDirectoryExist seihouModsDir
  if hasSeihouMods
    then pure ["(project modules directory exists at .seihou/modules/)"]
    else pure []

toModuleInfo :: DiscoveredModule -> [(Text, Text, Text)]
toModuleInfo dm = case dm.discoveredResult of
  Right m ->
    [ ( m.name.unModuleName,
        maybe "(no description)" id m.description,
        sourceLabel dm.discoveredSource
      )
    ]
  Left _ -> []

sourceLabel :: ModuleSource -> Text
sourceLabel SourceProject = "project"
sourceLabel SourceUser = "user"
sourceLabel SourceInstalled = "installed"

-- | Outcome of the optional baseline-application phase in
-- @seihou agent run@. Captured here in the library so the runner
-- (in @src-exe@) and the formatter share one shape.
data BaselineStatus
  = -- | The user passed @--no-baseline@; the runner skipped baseline application entirely.
    BaselineSkipped
  | -- | The blueprint declared no @baseModules@; nothing to apply.
    BaselineEmpty
  | -- | Baseline modules were applied. Each entry is the module's name and (optional) version.
    BaselineApplied [(ModuleName, Maybe Text)]
  deriving stock (Eq, Show)

-- | Render a blueprint's identity (name, version, description) as the
-- block embedded under "## Blueprint Identity" in the agent's system prompt.
formatBlueprintIdentity :: Blueprint -> Text
formatBlueprintIdentity bp =
  T.intercalate
    "\n"
    [ "Name: " <> bp.name.unModuleName,
      "Version: " <> fromMaybe "(unspecified)" bp.version,
      "Description: " <> fromMaybe "(no description)" bp.description
    ]

-- | Render the "## Baseline" body for the agent prompt.
formatBaselineStatus :: BaselineStatus -> Text
formatBaselineStatus BaselineSkipped =
  "(no baseline applied — `--no-baseline` was passed)"
formatBaselineStatus BaselineEmpty =
  "(this blueprint declares no base modules)"
formatBaselineStatus (BaselineApplied entries) =
  T.intercalate "\n" (map render entries)
  where
    render (n, Just v) = "  - " <> n.unModuleName <> " (v" <> v <> ")"
    render (n, Nothing) = "  - " <> n.unModuleName <> " (unversioned)"

-- | Render a blueprint's @files@ list as the body of the
-- "## Reference Files" block. When the directory exists, the interactive
-- runner mounts it via @--add-dir@ and 'formatReferenceFilesDir' prints its
-- path; this list helps the agent pick the right reference for the request.
formatReferenceFiles :: [BlueprintFile] -> Text
formatReferenceFiles [] = "(no reference files)"
formatReferenceFiles bfs = T.intercalate "\n" (map render bfs)
  where
    render bf = case bf.description of
      Just d -> "  - " <> T.pack bf.src <> " — " <> d
      Nothing -> "  - " <> T.pack bf.src

-- | Render guidance for the blueprint's reference-files directory. A
-- present path means the directory is mounted and readable by the interactive
-- agent; 'Nothing' means the provider cannot access it in this session.
formatReferenceFilesDir :: Maybe FilePath -> Text
formatReferenceFilesDir (Just dir) =
  "These files are readable at: "
    <> T.pack dir
    <> " — open them directly with your file tools before asking the user."
formatReferenceFilesDir Nothing =
  "These files are not mounted in this session; ask the user to paste any "
    <> "reference you need and never claim to have read one."

-- | Effective pre-approved tools for a blueprint run: the base set every
-- blueprint needs plus any tools it declares, de-duplicated and base-first.
-- No declaration yields exactly 'setupAllowedTools'.
resolveBlueprintTools :: Maybe [Text] -> [String]
resolveBlueprintTools declared =
  nub (setupAllowedTools <> map T.unpack (fromMaybe [] declared))