packages feed

baikai-kit 0.2.0.1 → 0.4.0.0

raw patch · 22 files changed

Files

CHANGELOG.md view
@@ -7,6 +7,221 @@  ## [Unreleased] +### Changed++- Documentation: the 2026-10-02 model refresh found no new OpenAI or Anthropic+  models; the catalog is unchanged. `docs/user/models-and-providers.md` notes+  Anthropic's retirement of Claude Sonnet 4.5 on 2026-11-30 (deprecation of+  `anthropic_claude_sonnet_4_5` is planned in plan 89 for removal in 0.8.0.0),+  why the access-gated Claude Mythos models are not curated, and how GPT-6+  Astra's new ultrafast service tier is costed.++## [baikai-kit 0.4.0.0] - 2026-10-02++### Added++- Per-item `visibility` (`tool-only` by default, or `shared`) for manifest+  versions 1 and 2, with `kit install --shared`, `--tool-only`, and+  `--accept-shared-codex`. Shared Claude skills and agents use tracked links;+  tool-only Codex skills use tracked disabled entries in `config.toml`.+- `codexSessionArgs`, required in the `extraArgs` of a consumer's own Codex+  launches to re-enable its tool-only skills. This is the one launcher step+  beyond a dependency bump; the standard `kitConfig`/`kitCommandParser`/`runKit`+  integration otherwise compiles unchanged. Codex custom agents cannot be+  isolated and require explicit acceptance, or shared visibility.+- Requested and effective visibility in status (table and JSON), the+  `visibility-broken` condition, and a migration note for legacy shared Codex+  skills. Update repairs visibility even for items skipped for local edits;+  uninstall removes only owned links and config entries.++### Changed (breaking)++- `SkillEntry` and `AgentEntry` gain `visibility`; `SidecarMeta` gains+  `visibility`, `visibilitySource`, `sharedLinks`, and `codexDisabledSkills`;+  `newSidecarMeta` takes these values. `RemovalOutcome` gains `linksRemoved`+  and `configEntriesRemoved`; `StatusRow` gains `requestedVisibility` and+  `effectiveVisibility`; `KitConfig` gains `confirmSharedCodex` (default+  `Nothing` in `kitConfig`).+- `KitInstall` gains `InstallOptions`; `installItem` and `installFrom` take+  it as their last argument. Use `defaultInstallOptions` to follow the+  manifest. Explicit install flags persist across updates, while new+  manifest-driven installs follow updated defaults. Legacy placement is kept.+- JSON adds list `visibility`, status `requestedVisibility` and+  `effectiveVisibility`, and the new condition value. `formatVersion` stays+  1 because all additions preserve existing keys and their meaning.++### Fixed++- Codex installs refuse destinations without this tool's sidecar instead of+  overwriting user or other-tool assets. Uninstall also preserves foreign+  Codex assets. Shared Claude names are checked before any provider write.+- Codex config edits preserve UTF-8 text, comments and permissions, refuse+  symlinks and invalid/conflicting config, and verify the semantic change+  before atomic rename. Reused user-owned disabled entries survive uninstall.+  Shared visibility refuses a user-owned entry that would keep the skill hidden.+- The source distribution ships the test suite's manifest fixtures and JSON+  goldens, so the test suite passes when run from the Hackage tarball. Since+  0.3.0.0 it had failed there, because those files were missing.++## [baikai 0.7.2.0] - 2026-09-30++### Added++- Curated GPT-6.1 Sol on OpenAI Responses and Claude Sonnet 5.5 on Anthropic+  Messages (`openai_gpt_6_1_sol`, `anthropic_claude_sonnet_5_5`), with endpoint+  compatibility facts, standard prices, GPT-6.1 Sol's 272K-token context tier,+  and Sonnet 5.5's one-hour cache-write rate. Sonnet 5.5 rejects forced tool+  choice locally and has no fast mode. Both passed live text and function-tool+  acceptance on 2026-09-30 through `/v1/responses` and `/v1/messages`+  ([record](docs/validation/plan-85/2026-09-30-complete.json),+  [plan 85](docs/plans/85-prove-gpt-6-1-sol-and-claude-sonnet-5-5-live-compatibility.md)).+  The focused smoke runner gains the `sol61-*` and `sonnet55-*` cases.+- `Baikai.ResponseFormat.StructuredOutputSupport`+  (`NativeJsonSchema | NoStructuredOutput`), `declaredStructuredOutput :: Api ->+  StructuredOutputSupport`, and a `structuredOutput` field on `ApiProvider`+  (default `NoStructuredOutput` in `apiProviderWith`), so a caller can ask+  whether a transport enforces a schema without calling it. Every built-in+  provider declares `NativeJsonSchema`.++## [baikai-claude 0.7.1.0] - 2026-09-30++Requires `baikai >=0.7.2`.++### Added++- `claude -p` honours a `JsonSchema` response format (IR-11): it receives+  `--json-schema '<schema>'` and the response text is the tool's validated+  `structured_output`. A missing `structured_output` is a `DecodeFailure`; a+  `claude` too old for the flag yields an `InvalidRequest` error with the exit+  code rather than unconstrained text. `JsonObject`, `name` and `strict` are not+  forwarded; requests without a schema render the same argument vector as+  before. Both Claude providers declare `structuredOutput = NativeJsonSchema`.++### Fixed++- Anthropic adaptive `ThinkingHigh` now sends+  `output_config.effort: "high"` instead of omitting the field. Claude Opus 5.5+  defaults to `medium`, so it previously ran `ThinkingHigh` at medium effort;+  other adaptive models default to `high` and behave as before. Evidence for+  these calls records `effortText = "high"` and no longer carries+  `effort_omitted`, so strict evidence mode no longer refuses them.+  `Baikai.Evidence.EffortOmitted` stays exported, and older records still decode.++## [baikai-openai 0.7.1.0] - 2026-09-30++Requires `baikai >=0.7.2`.++### Added++- `codex exec` honours a `JsonSchema` response format (IR-11): the schema is+  written to a temporary file passed as `--output-schema <file>` and deleted+  however the call ends. A `codex` too old for the flag yields an+  `InvalidRequest` error with the exit code rather than unconstrained text.+  `JsonObject`, `name` and `strict` are not forwarded; requests without a schema+  render the same argument vector as before. All three OpenAI providers declare+  `structuredOutput = NativeJsonSchema`.+- `codexCliCommandWith`, which renders the `codex exec`+  vector with a given `--output-schema` file. `codexCliCommand` is unchanged+  and never renders the flag.++## [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,36 @@-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.4.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+extra-source-files:+  test/fixtures/*.json+  test/golden/*.json  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 +45,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,50 +57,63 @@     OverloadedStrings  library-  import:          common-options-  hs-source-dirs:  src+  import: common-options+  hs-source-dirs: src   exposed-modules:     Baikai.Kit+    Baikai.Kit.CodexConfig     Baikai.Kit.Command     Baikai.Kit.Config     Baikai.Kit.Error     Baikai.Kit.Install+    Baikai.Kit.Json     Baikai.Kit.Manifest     Baikai.Kit.Path     Baikai.Kit.Repo     Baikai.Kit.Session     Baikai.Kit.Sidecar     Baikai.Kit.Status+    Baikai.Kit.Visibility +  other-modules: Baikai.Kit.Link   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,+    containers ^>=0.7,+    crypton ^>=1.0,+    directory ^>=1.3,+    filepath ^>=1.5,+    optparse-applicative ^>=0.19,+    process ^>=1.6,+    text ^>=2.1,+    time ^>=1.14,+    toml-parser ^>=2.0.2,  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,+    toml-parser,+    unix,
src/Baikai/Kit.hs view
@@ -1,24 +1,30 @@ module Baikai.Kit   ( module Baikai.Kit.Command,     module Baikai.Kit.Config,+    module Baikai.Kit.CodexConfig,     module Baikai.Kit.Error,     module Baikai.Kit.Install,+    module Baikai.Kit.Json,     module Baikai.Kit.Manifest,     module Baikai.Kit.Path,     module Baikai.Kit.Repo,     module Baikai.Kit.Session,     module Baikai.Kit.Sidecar,     module Baikai.Kit.Status,+    module Baikai.Kit.Visibility,   ) where +import Baikai.Kit.CodexConfig import Baikai.Kit.Command 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 import Baikai.Kit.Session import Baikai.Kit.Sidecar import Baikai.Kit.Status+import Baikai.Kit.Visibility
+ src/Baikai/Kit/CodexConfig.hs view
@@ -0,0 +1,298 @@+-- | Preserve the user's TOML text; parse both sides of every surgical edit.+module Baikai.Kit.CodexConfig+  ( codexConfigPath,+    readSkillEntries,+    addDisabledSkill,+    removeDisabledSkill,+    checkDisabledSkill,+    checkRemoveDisabledSkill,+    enableSkillsArgs,+  )+where++import Baikai.Kit.Error (KitError (..))+import Baikai.Prelude+import Control.Exception (IOException, onException, try)+import Control.Monad (forM_, unless, when)+import Data.ByteString qualified as BS+import Data.Char (ord)+import Data.List (nub)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Encoding+import Numeric (showHex)+import System.Directory+  ( canonicalizePath,+    createDirectoryIfMissing,+    doesDirectoryExist,+    doesFileExist,+    getHomeDirectory,+    getPermissions,+    pathIsSymbolicLink,+    readable,+    removeFile,+    renameFile,+    setPermissions,+    writable,+  )+import System.Environment (lookupEnv)+import System.FilePath (takeDirectory, takeFileName, (</>))+import System.IO (hClose, openTempFile)+import Toml qualified++codexConfigPath :: IO FilePath+codexConfigPath = do+  home <- getHomeDirectory+  root <- fromMaybe (home </> ".codex") <$> lookupEnv "CODEX_HOME"+  pure (root </> "config.toml")++readSkillEntries :: FilePath -> IO (Either KitError [(FilePath, Bool)])+readSkillEntries config = configTry config "" $ do+  text <- readConfig config+  pure $ do+    table <- parseConfig text+    traverse entry (configValues table)+  where+    entry (Toml.Table t) = do+      p <- case value "path" t of+        Just (Toml.Text p) -> pure (Text.unpack p)+        _ -> Left "skills.config entry has no string path"+      enabled <- case value "enabled" t of+        Nothing -> Right True+        Just (Toml.Bool b) -> Right b+        _ -> Left "skills.config entry has no boolean enabled"+      Right (p, enabled)+    entry _ = Left "skills.config must contain tables"++-- | Validate without writing: True means we would add and own this entry.+checkDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)+checkDisabledSkill config skill = fmap (fmap (has _Just)) (prepareEdit True config skill)++checkRemoveDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)+checkRemoveDisabledSkill config skill = fmap (fmap (has _Just)) (prepareEdit False config skill)++addDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)+addDisabledSkill = editSkill True++removeDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)+removeDisabledSkill = editSkill False++editSkill :: Bool -> FilePath -> FilePath -> IO (Either KitError Bool)+editSkill adding config skill = do+  prepared <- prepareEdit adding config skill+  case prepared of+    Left err -> pure (Left err)+    Right Nothing -> pure (Right False)+    Right (Just text) -> configTry config skill $ do+      atomicWrite config text+      pure (Right True)++prepareEdit :: Bool -> FilePath -> FilePath -> IO (Either KitError (Maybe Text))+prepareEdit adding config skill = configTry config skill $ do+  linked <- either (const False) id <$> try @IOException (pathIsSymbolicLink config)+  if linked+    then pure (Left "config.toml is a symbolic link (possibly generated); refusing to replace it")+    else do+      exists <- doesFileExist config+      permissions <- if exists then Just <$> getPermissions config else pure Nothing+      case permissions of+        Just perms | not (readable perms && writable perms) -> ioError (userError "config.toml is not readable and writable")+        _ -> pure ()+      checkParentWritable (takeDirectory config)+      text <- readConfig config+      case parseConfig text of+        Left err -> pure (Left err)+        Right old -> do+          absolute <- canonicalizePath skill+          matches <- traverse (matchesPath absolute) (configValues old)+          let matchedEntries = [v | (v, True) <- zip (configValues old) matches]+          pure $ do+            selected <- case matchedEntries of+              [] -> Right Nothing+              [v@(Toml.Table t)] -> case value "enabled" t of+                Just (Toml.Bool False) -> Right (Just v)+                _+                  | adding -> Left "the user already enabled this skill; refusing to override that entry"+                  | otherwise -> Right Nothing+              _ -> Left "multiple skills.config entries name this path"+            case (adding, selected) of+              (True, Just _) -> Right Nothing+              (False, Nothing) -> Right Nothing+              _ -> do+                let newText = if adding then appendBlock text absolute else removeBlock text absolute+                    expected =+                      if adding+                        then configValues old ++ [disabledValue absolute]+                        else filter (\v -> Just v /= selected) (configValues old)+                new <- parseConfig newText+                unless (configValues new == expected && withoutConfig old == withoutConfig new) $+                  Left "the edit would change other TOML values; use array-of-table [[skills.config]] entries"+                Right (Just newText)+  where+    matchesPath absolute (Toml.Table t) = case value "path" t of+      Just (Toml.Text p) -> (== absolute) <$> canonicalizePath (Text.unpack p)+      _ -> pure False+    matchesPath _ _ = pure False++checkParentWritable :: FilePath -> IO ()+checkParentWritable path = do+  exists <- doesDirectoryExist path+  if exists+    then do+      permissions <- getPermissions path+      unless (writable permissions) (ioError (userError "Codex config directory is not writable"))+    else do+      let parent = takeDirectory path+      when (parent == path || parent == "/") (ioError (userError "Codex config has no writable parent"))+      checkParentWritable parent++readConfig :: FilePath -> IO Text+readConfig config = do+  exists <- doesFileExist config+  if not exists+    then pure ""+    else do+      bytes <- BS.readFile config+      either (ioError . userError . show) pure (Encoding.decodeUtf8' bytes)++parseConfig :: Text -> Either Text Toml.Table+parseConfig text = do+  t <- either (Left . Text.pack) (Right . Toml.forgetTableAnns) (Toml.parse text)+  case value "skills" t of+    Nothing -> Right t+    Just (Toml.Table skills) -> case value "config" skills of+      Nothing -> Right t+      Just (Toml.List _) -> Right t+      _ -> Left "skills.config is not an array"+    _ -> Left "skills is not a table"++value :: Text -> Toml.Table -> Maybe Toml.Value+value key (Toml.MkTable t) = snd <$> Map.lookup key t++configValues :: Toml.Table -> [Toml.Value]+configValues t = case value "skills" t of+  Just (Toml.Table skills) -> case value "config" skills of+    Just (Toml.List values) -> values+    _ -> []+  _ -> []++withoutConfig :: Toml.Table -> Toml.Table+withoutConfig (Toml.MkTable t) = Toml.MkTable $ Map.update clean "skills" t+  where+    clean (_, Toml.Table (Toml.MkTable skills)) =+      let rest = Map.delete "config" skills+       in if Map.null rest then Nothing else Just ((), Toml.Table (Toml.MkTable rest))+    clean other = Just other++disabledValue :: FilePath -> Toml.Value+disabledValue skill =+  Toml.Table+    ( Toml.MkTable+        ( Map.fromList+            [("path", ((), Toml.Text (Text.pack skill))), ("enabled", ((), Toml.Bool False))]+        )+    )++disabledBlock :: FilePath -> Text+disabledBlock skill = "[[skills.config]]\npath = " <> tomlString (Text.pack skill) <> "\nenabled = false\n"++-- Mark the separator so uninstall restores even a seed with no final newline.+appendBlock :: Text -> FilePath -> Text+appendBlock text skill = text <> "\n# baikai-kit begin\n" <> disabledBlock skill <> "# baikai-kit end\n"++removeBlock :: Text -> FilePath -> Text+removeBlock text skill =+  let marked = go "" (Text.splitOn "\n# baikai-kit begin\n" text)+   in if marked /= text then marked else removeUnmarked text skill+  where+    go prefix [] = prefix+    go prefix [lastPart] = prefix <> lastPart+    go prefix (part : block : remaining) =+      let (body, suffix) = Text.breakOn "# baikai-kit end\n" block+          matches = case parseConfig body of+            Right table -> configValues table == [disabledValue skill]+            Left _ -> False+       in if matches && not (Text.null suffix)+            then+              prefix+                <> part+                <> Text.drop (Text.length "# baikai-kit end\n") suffix+                <> Text.concat ["\n# baikai-kit begin\n" <> r | r <- remaining]+            else go (prefix <> part <> "\n# baikai-kit begin\n") (block : remaining)++-- A user may remove our delimiter comments. Locate the table block; the+-- caller still verifies that removing it changes exactly one parsed entry.+removeUnmarked :: Text -> FilePath -> Text+removeUnmarked text skill = go [] (Text.splitOn "\n" text)+  where+    go before [] = Text.intercalate "\n" before+    go before (line : rest)+      | Text.strip (Text.takeWhile (/= '#') line) == "[[skills.config]]" =+          let (body, after) = break (Text.isPrefixOf "[" . Text.stripStart) rest+              block = Text.intercalate "\n" (line : body)+              matches = case parseConfig block of+                Right table -> case configValues table of+                  [Toml.Table entry] ->+                    value "path" entry == Just (Toml.Text (Text.pack skill))+                      && value "enabled" entry == Just (Toml.Bool False)+                  _ -> False+                Left _ -> False+           in if matches+                then Text.intercalate "\n" (before ++ after)+                else go (before ++ [line]) rest+      | otherwise = go (before ++ [line]) rest++atomicWrite :: FilePath -> Text -> IO ()+atomicWrite path text = do+  let dir = takeDirectory path+  createDirectoryIfMissing True dir+  exists <- doesFileExist path+  permissions <- if exists then Just <$> getPermissions path else pure Nothing+  (temp, handle) <- openTempFile dir (takeFileName path <> ".baikai-kit-tmp")+  ( do+      BS.hPut handle (Encoding.encodeUtf8 text)+      hClose handle+      forM_ permissions (setPermissions temp)+      renameFile temp path+    )+    `onException` (hClose handle >> removeFile temp)++configTry :: FilePath -> FilePath -> IO (Either Text a) -> IO (Either KitError a)+configTry config skill action = do+  result <- try @IOException action+  pure $ case result of+    Left e -> Left (failure (Text.pack (show e)))+    Right (Left reason) -> Left (failure reason)+    Right (Right v) -> Right v+  where+    failure reason =+      KitCodexConfigUnusable+        config+        ( reason+            <> "\nAdd this block by hand:\n"+            <> disabledBlock skill+            <> "Use --shared to install without the entry. --accept-shared-codex applies to agents that cannot be isolated."+        )++enableSkillsArgs :: [FilePath] -> [Text]+enableSkillsArgs [] = []+enableSkillsArgs skills =+  [ "-c",+    "skills.config=["+      <> Text.intercalate+        ","+        ["{path=" <> tomlString (Text.pack p) <> ",enabled=true}" | p <- nub skills]+      <> "]"+  ]++tomlString :: Text -> Text+tomlString input = "\"" <> Text.concatMap escape input <> "\""+  where+    escape '"' = "\\\""+    escape '\\' = "\\\\"+    escape c+      | ord c < 32 || ord c == 127 =+          let digits = showHex (ord c) ""+           in "\\u" <> Text.pack (replicate (4 - length digits) '0' ++ digits)+    escape c = Text.singleton c
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,9 +13,10 @@ 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 (..),+  ( InstallOptions (..),+    OverwritePolicy (..),     UpdateReport,     installFrom,     loadManifest,@@ -23,61 +25,99 @@     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.Kit.Visibility (KitVisibility (..)) 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 !InstallOptions+  | 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 options -> 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 options+  -- Without a chooser nothing the refresh could do changes the outcome,+  -- so fail before touching the network.+  KitInstall Nothing scope options -> 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 options+  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 -> InstallOptions -> IO (Either KitError ())+    installNamed repo manifest n scope options =+      installFrom config (repo ^. #dir) manifest n scope options `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 +126,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 +151,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 +207,23 @@       | 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")+    <*> ( InstallOptions+            <$> optional+              ( flag' SharedVisibility (long "shared" <> help "Make the item visible in every Claude Code and Codex session")+                  <|> flag' ToolOnlyVisibility (long "tool-only" <> help "Make the item visible only in sessions this tool launches")+              )+            <*> switch (long "accept-shared-codex" <> help "Accept shared visibility when Codex cannot isolate an agent")+        )  updateParser :: Parser KitCommand updateParser =@@ -166,13 +233,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,11 +1,15 @@ module Baikai.Kit.Config   ( KitConfig (..),     KitScope (..),+    kitConfig,+    findProjectRoot,+    projectRootByMarkers,     kitCacheDir,     userAgentsDir,     projectAgentsDir,     resolveAgentsBase,     providerAgentsBase,+    sharedClaudeBase,     providerLabel,     sidecarFileName,     scopeLabel,@@ -14,18 +18,92 @@  import Baikai.AgentAssets (AgentAssetProvider) import Baikai.Interactive (InteractiveProvider (..))+import Baikai.Kit.Manifest (KitItem, 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))),+    -- | Confirmation for a tool-only agent whose Codex copy must be shared.+    confirmSharedCodex :: !(Maybe (KitItem -> IO Bool))   }-  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 ", confirmSharedCodex = "+        . showString (maybe "Nothing" (const "Just <confirmation>") (config ^. #confirmSharedCodex))+        . 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,+      confirmSharedCodex = 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 +119,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 +133,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"@@ -65,3 +145,8 @@ scopeLabel :: KitScope -> Text scopeLabel UserScope = "user" scopeLabel ProjectScope = "project"++-- | The provider-native shared root; project scope uses the configured root.+sharedClaudeBase :: KitConfig -> KitScope -> IO FilePath+sharedClaudeBase _ UserScope = getHomeDirectory+sharedClaudeBase config ProjectScope = config ^. #projectRoot
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@@ -50,6 +53,10 @@     --   restored by rollback, and the destinations left inconsistent.     --   Both lists are empty when nothing was changed.     KitWriteFailed Text [FilePath] [FilePath]+  | KitSharedNameTaken FilePath (Maybe Text)+  | KitCodexConfigUnusable FilePath Text+  | KitCodexCannotIsolate Text+  | KitVisibilityNotApplied Text [FilePath]   deriving stock (Eq, Show)   deriving anyclass (Exception) @@ -70,6 +77,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@@ -89,6 +98,17 @@     "failed to update kit repository: "       <> output       <> "\nThe cached copy is unchanged; installed items were not reinstalled."+  KitSharedNameTaken path owner ->+    Text.pack path+      <> " already exists and was not created by this tool"+      <> maybe "" (\tool -> " (it belongs to " <> tool <> ")") owner+      <> "; refusing to replace it."+  KitCodexConfigUnusable path reason ->+    "cannot edit Codex config " <> Text.pack path <> ": " <> reason+  KitCodexCannotIsolate n ->+    "'" <> n <> "' is a tool-only agent, but Codex cannot hide a custom agent from other sessions: its Codex copy would be visible to every Codex session. Pass --accept-shared-codex to install it anyway, or --shared."+  KitVisibilityNotApplied reason paths ->+    "visibility was not applied: " <> reason <> " (" <> Text.intercalate ", " (map Text.pack paths) <> "); run 'kit update NAME' to retry."   KitWriteFailed reason restored leftInconsistent ->     Text.intercalate "\n" ("install failed: " <> reason : aftermath)     where
src/Baikai/Kit/Install.hs view
@@ -6,7 +6,13 @@ --   exported boundary, which keeps the plumbing readable without changing --   what a caller observes. module Baikai.Kit.Install-  ( loadManifest,+  ( InstallOptions (..),+    defaultInstallOptions,+    VisibilityCheck (..),+    checkVisibility,+    checkVisibilityWithEntries,+    relativeLinkTarget,+    loadManifest,     loadManifestMaybe,     lookupItem,     installItem,@@ -18,6 +24,8 @@     UpdateReport (..),     updateKit,     reinstallPresent,+    LocalEdits (..),+    checkLocalEdits,     listAvailable,     renderAvailable,     PlannedWrite (..),@@ -29,14 +37,17 @@ where  import Baikai.AgentAssets-  ( CodexCustomAgent (..),+  ( AgentAssetProvider,+    CodexCustomAgent (..),     agentTargetPath,     codexCustomAgentToml,     skillTargetPath,   ) import Baikai.Interactive (InteractiveProvider (..), InteractiveScope (InteractiveProjectScope))+import Baikai.Kit.CodexConfig (addDisabledSkill, checkDisabledSkill, checkRemoveDisabledSkill, codexConfigPath, readSkillEntries, removeDisabledSkill) import Baikai.Kit.Config (KitConfig, KitScope (..), kitCacheDir, providerAgentsBase, providerLabel, scopeLabel, sidecarFileName) import Baikai.Kit.Error (KitError (..))+import Baikai.Kit.Link (SharedLink, applyLink, claudeLinks, foreignOwner, linkIsOurs, pathExists, preflightLink, relativeLinkTarget, removeLink) import Baikai.Kit.Manifest   ( AgentEntry,     ItemSources,@@ -46,19 +57,22 @@     SkillEntry,     itemName,     itemSources,+    itemVisibility,     kitItemKind,     supportedManifestVersions,   ) import Baikai.Kit.Path (safeItemName, safeSourcePath) import Baikai.Kit.Repo (KitRepo, PullResult (..), RepoRefresh (..), ensureKitRepo, pullKitRepo)-import Baikai.Kit.Sidecar (hashEntries, newSidecarMeta, readSidecar, sidecarPath)+import Baikai.Kit.Sidecar (SidecarMeta, hashEntries, newSidecarMeta, readSidecar, sidecarPath)+import Baikai.Kit.Visibility (KitVisibility (..), parseVisibility, visibilityLabel) import Baikai.Prelude import Control.Exception (IOException, onException, throwIO, try)-import Control.Monad (forM, forM_, unless, when)+import Control.Monad (filterM, forM, forM_, unless, when) import Data.Aeson (eitherDecodeFileStrict', encode) import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as LBS import Data.List (find, nub)+import Data.Maybe (fromMaybe) import Data.Text qualified as Text import Data.Text.Encoding qualified as Text.Encoding import System.Directory@@ -69,9 +83,19 @@     removeFile,     renameFile,   )+import System.Directory qualified import System.FilePath (takeDirectory, takeFileName, (</>)) import System.IO (hClose, openTempFile) +data InstallOptions = InstallOptions+  { visibility :: !(Maybe KitVisibility),+    acceptSharedCodex :: !Bool+  }+  deriving stock (Eq, Generic, Show)++defaultInstallOptions :: InstallOptions+defaultInstallOptions = InstallOptions Nothing False+ -- | One file this install will put in place. Exposed as a test seam --   together with 'executePlanWith'; not part of the stable surface. data PlannedWrite = PlannedWrite@@ -89,7 +113,9 @@   { provider :: !InteractiveProvider,     skillRemoved :: !Bool,     agentRemoved :: !Bool,-    sidecarRemoved :: !Bool+    sidecarRemoved :: !Bool,+    linksRemoved :: ![FilePath],+    configEntriesRemoved :: ![FilePath]   }   deriving stock (Eq, Generic, Show) @@ -132,17 +158,17 @@ --   'KitRepo'\'s refresh state is dropped by this convenience; a command --   that wants to warn about a stale cache composes 'ensureKitRepo', --   'loadManifest' and 'installFrom' itself.-installItem :: KitConfig -> Text -> KitScope -> IO (Either KitError KitItem)-installItem config itemN scope = kitTry $ do+installItem :: KitConfig -> Text -> KitScope -> InstallOptions -> IO (Either KitError KitItem)+installItem config itemN scope options = kitTry $ do   repo <- requireRepo config   manifest <- loadManifestIO (repo ^. #dir)-  installFromIO config (repo ^. #dir) manifest itemN scope+  installFromIO config (repo ^. #dir) manifest itemN scope options  -- | The network-free half of 'installItem': install one item from a --   manifest already read out of @repoDir@.-installFrom :: KitConfig -> FilePath -> KitManifest -> Text -> KitScope -> IO (Either KitError KitItem)-installFrom config repoDir manifest itemN scope =-  kitTry (installFromIO config repoDir manifest itemN scope)+installFrom :: KitConfig -> FilePath -> KitManifest -> Text -> KitScope -> InstallOptions -> IO (Either KitError KitItem)+installFrom config repoDir manifest itemN scope options =+  kitTry (installFromIO config repoDir manifest itemN scope options)  -- | Remove an item's assets, its resource directory and its sidecar from --   every provider at one scope.@@ -151,18 +177,38 @@   safeName <- orThrow (KitUnsafeName n) (safeItemName n)   forM (config ^. #providers) $ \provider -> do     providerBase <- providerAgentsBase config provider scope-    skillRemoved <- removeIfDirectory (skillTarget config provider providerBase safeName)-    agentFileRemoved <- removeIfFile (agentTarget config provider providerBase safeName)+    old <- traverse (\kind -> readSidecar (sidecarPath provider kind n providerBase (sidecarFileName config))) [SkillKind, AgentKind]+    links <-+      if provider == InteractiveClaude+        then do+          skillLinks <- claudeLinks config scope SkillKind n False+          agentLinks <- claudeLinks config scope AgentKind n True+          fmap concat . forM (skillLinks ++ agentLinks) $ \link -> do+            removed <- removeLink link+            pure [link ^. #location | removed]+        else pure []+    -- Sidecars are read before deleting assets so owned entries remain known.+    removedEntries <- fmap concat . forM old $ \meta ->+      fmap concat . forM (fromMaybe [] (meta >>= (^. #codexDisabledSkills))) $ \raw -> do+        path <- codexConfigPath+        removed <- removeDisabledSkill path (Text.unpack raw) >>= either throwIO pure+        pure [Text.unpack raw | removed]+    let ownsSkill = provider /= InteractiveCodex || any (maybe False ((== "skill") . view #kind)) old+        ownsAgent = provider /= InteractiveCodex || any (maybe False ((== "agent") . view #kind)) old+    skillRemoved <- if ownsSkill then removeIfDirectory (skillTarget config provider providerBase safeName) else pure False+    agentFileRemoved <- if ownsAgent then removeIfFile (agentTarget config provider providerBase safeName) else pure False     -- A multi-file agent owns a directory beside its agent file; it is     -- part of the agent, not a kind of its own.-    agentDirRemoved <- removeIfDirectory (agentResourceDir config provider providerBase safeName)+    agentDirRemoved <- if ownsAgent then removeIfDirectory (agentResourceDir config provider providerBase safeName) else pure False     sidecarRemoved <- removeIfFile (agentSidecarTarget config provider providerBase safeName)     pure       RemovalOutcome         { provider,           skillRemoved,           agentRemoved = agentFileRemoved || agentDirRemoved,-          sidecarRemoved+          sidecarRemoved,+          linksRemoved = links,+          configEntriesRemoved = removedEntries         }  renderUninstallReport :: Text -> KitScope -> [RemovalOutcome] -> Text@@ -177,12 +223,24 @@         <> " scope ("         <> Text.intercalate "," removedProviders         <> ")."+        <> visibilityRemovalNote   | any (^. #sidecarRemoved) outcomes =-      "Removed stale kit metadata for '" <> n <> "' from " <> scopeLabel scope <> " scope."+      "Removed stale kit metadata for '" <> n <> "' from " <> scopeLabel scope <> " scope." <> visibilityRemovalNote+  | any visibilityRemoved outcomes =+      "Removed stale visibility for '" <> n <> "' from " <> scopeLabel scope <> " scope." <> visibilityRemovalNote   | otherwise =       "'" <> n <> "' is not installed in " <> scopeLabel scope <> " scope."   where+    visibilityRemovalNote =+      let links = sum (map (length . view #linksRemoved) outcomes)+          entries = sum (map (length . view #configEntriesRemoved) outcomes)+       in Text.concat+            [ " Removed " <> Text.pack (show count) <> " " <> label <> "."+            | (count, label) <- [(links, "shared link(s)"), (entries, "Codex config entry/entries")],+              count > 0+            ]     assetRemoved outcome = outcome ^. #skillRemoved || outcome ^. #agentRemoved+    visibilityRemoved outcome = not (null (outcome ^. #linksRemoved) && null (outcome ^. #configEntriesRemoved))     removedKinds =       nub $         ["skill" | any (^. #skillRemoved) outcomes]@@ -377,12 +435,13 @@         throwIO (KitManifestVersionUnsupported manifestPath declared)       pure manifest -installFromIO :: KitConfig -> FilePath -> KitManifest -> Text -> KitScope -> IO KitItem-installFromIO config repoDir manifest itemN scope =+installFromIO :: KitConfig -> FilePath -> KitManifest -> Text -> KitScope -> InstallOptions -> IO KitItem+installFromIO config repoDir manifest itemN scope options =   case lookupItem itemN manifest of     Nothing -> throwIO (KitItemNotFound itemN)     Just item -> do-      doInstall config repoDir item scope+      choices <- prepareVisibility config item scope (Just options)+      doInstall config repoDir item scope choices       pure item  requireRepo :: KitConfig -> IO KitRepo@@ -405,10 +464,13 @@             modified <- case policy of               OverwriteLocalEdits -> pure False               KeepLocalEdits -> locallyModified config item scope+            choices <- prepareVisibility config item scope Nothing             if modified-              then pure [Left (n, scope)]+              then do+                repairSkippedVisibility choices+                pure [Left (n, scope)]               else do-                doInstall config repoDir item scope+                doInstall config repoDir item scope choices                 pure [Right (n, scope)]           _ -> pure []   pure@@ -424,36 +486,312 @@         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-  writes <- planInstall config repoDir item scope+-- | The one visibility check shared by status and update. An absent+-- requested value marks an installation that predates visibility tracking.+data VisibilityCheck = VisibilityCheck+  { requested :: !(Maybe KitVisibility),+    effective :: !KitVisibility,+    broken :: ![FilePath]+  }+  deriving stock (Eq, Generic, Show)++checkVisibility :: KitConfig -> AgentAssetProvider -> KitScope -> KitItemKind -> Text -> IO (Either KitError VisibilityCheck)+checkVisibility config provider scope kind n = do+  entries <-+    if provider == InteractiveCodex && kind == SkillKind+      then codexConfigPath >>= readSkillEntries+      else pure (Right [])+  checkVisibilityWithEntries entries config provider scope kind n++-- | A status run supplies one parsed Codex snapshot for every row.+checkVisibilityWithEntries :: Either KitError [(FilePath, Bool)] -> KitConfig -> AgentAssetProvider -> KitScope -> KitItemKind -> Text -> IO (Either KitError VisibilityCheck)+checkVisibilityWithEntries entries config provider scope kind n = kitTry $ do+  _ <- orThrow (KitUnsafeName n) (safeItemName n)+  base <- providerAgentsBase config provider scope+  meta <- readSidecar (sidecarPath provider kind n base (sidecarFileName config))+  let requested = meta >>= (^. #visibility) >>= either (const Nothing) Just . parseVisibility+  case provider of+    InteractiveClaude -> do+      links <- claudeLinks config scope kind n True+      states <- forM links $ \link -> do+        ours <- linkIsOurs link+        exists <- System.Directory.doesPathExist (link ^. #location)+        pure (link ^. #location, ours && exists)+      let shared = case states of+            (_, True) : _ -> True+            _ -> False+          recorded = fromMaybe [] (meta >>= (^. #sharedLinks))+          hasResources = maybe False ((> 1) . length) (meta >>= (^. #installedFiles))+          wanted =+            [ p+            | (ordinal, (p, _)) <- zip [0 :: Int ..] states,+              requested == Just SharedVisibility,+              ordinal == 0 || hasResources+            ]+          broken =+            [ p+            | raw <- recorded,+              let p = Text.unpack raw,+              if p `elem` wanted then lookup p states /= Just True else lookup p states == Just True+            ]+      pure VisibilityCheck {requested, effective = if shared then SharedVisibility else ToolOnlyVisibility, broken}+    InteractiveCodex+      | kind == AgentKind ->+          pure VisibilityCheck {requested, effective = SharedVisibility, broken = []}+    InteractiveCodex -> do+      path <- System.Directory.canonicalizePath (skillTarget config provider base (Text.unpack n) </> "SKILL.md")+      canonical <- case entries of+        Left _ -> pure []+        Right values -> forM values $ \(p, enabled) -> do+          absolute <- System.Directory.canonicalizePath p+          pure (absolute, enabled)+      let disabled p = (p, False) `elem` canonical && (p, True) `notElem` canonical+          tracked = map Text.unpack (fromMaybe [] (meta >>= (^. #codexDisabledSkills)))+      trackedPaths <- traverse System.Directory.canonicalizePath tracked+      let broken = nub [p | p <- trackedPaths ++ [path | has _Just requested], if requested == Just SharedVisibility then disabled p else not (disabled p)]+      pure VisibilityCheck {requested, effective = if disabled path then ToolOnlyVisibility else SharedVisibility, broken}++-- Visibility intent is prepared for every provider before writing any asset.+data ProviderVisibility = ProviderVisibility+  { provider :: !AgentAssetProvider,+    baseDir :: !FilePath,+    sidecarFile :: !FilePath,+    previous :: !(Maybe SidecarMeta),+    requested :: !(Maybe KitVisibility),+    source :: !(Maybe Text),+    links :: ![SharedLink],+    oldLinks :: ![SharedLink],+    configFile :: !FilePath,+    disablePaths :: ![FilePath],+    trackedDisabled :: ![Text],+    oldDisabled :: ![Text]+  }+  deriving stock (Generic)++prepareVisibility :: KitConfig -> KitItem -> KitScope -> Maybe InstallOptions -> IO [ProviderVisibility]+prepareVisibility config item scope options = do+  case options of+    Just opts+      | InteractiveCodex `elem` (config ^. #providers),+        kitItemKind item == AgentKind,+        fromMaybe (itemVisibility item) (opts ^. #visibility) == ToolOnlyVisibility -> do+          accepted <-+            if opts ^. #acceptSharedCodex+              then pure True+              else maybe (pure False) ($ item) (config ^. #confirmSharedCodex)+          unless accepted (throwIO (KitCodexCannotIsolate (itemName item)))+    _ -> pure ()+  providers <- case options of+    Just _ -> pure (config ^. #providers)+    Nothing -> filterM (installedProvider config item scope) (config ^. #providers)+  forM providers $ \provider -> do+    baseDir <- providerAgentsBase config provider scope+    let n = itemName item+        kind = kitItemKind item+        sidecarFile = sidecarPath provider kind n baseDir (sidecarFileName config)+        assetPath = case kind of+          SkillKind -> skillTarget config provider baseDir (Text.unpack n)+          AgentKind -> agentTarget config provider baseDir (Text.unpack n)+    previous <- readSidecar sidecarFile+    current <- checkVisibility config provider scope kind n >>= either throwIO pure+    when (provider == InteractiveCodex) $ do+      let protected = assetPath : [agentResourceDir config provider baseDir (Text.unpack n) | kind == AgentKind]+      forM_ protected $ \path -> do+        exists <- pathExists path+        when (exists && not (has _Just previous)) $ do+          owner <- foreignOwner path+          throwIO (KitSharedNameTaken path owner)+    let (requested, source) = case options of+          Just opts ->+            ( Just (fromMaybe (itemVisibility item) (opts ^. #visibility)),+              Just (if has _Just (opts ^. #visibility) then "install-flag" else "manifest")+            )+          Nothing -> case current ^. #requested of+            Nothing -> (Nothing, Nothing)+            Just old ->+              if (previous >>= (^. #visibilitySource)) == Just "install-flag"+                then (Just old, Just "install-flag")+                else (Just (itemVisibility item), Just "manifest")+    sources <- either throwIO pure (itemSources item)+    possible <-+      if provider == InteractiveClaude+        then claudeLinks config scope kind n (kind == AgentKind && length (sources ^. #files) > 1)+        else pure []+    allLinks <- if provider == InteractiveClaude then claudeLinks config scope kind n True else pure []+    let links = [link | link <- possible, requested == Just SharedVisibility]+        recorded = fromMaybe [] (previous >>= (^. #sharedLinks))+    owned <- forM allLinks linkIsOurs+    let oldLinks = [link | (link, ours) <- zip allLinks owned, ours || Text.pack (link ^. #location) `elem` recorded]+    forM_ links preflightLink+    configFile <- codexConfigPath+    let oldDisabled = fromMaybe [] (previous >>= (^. #codexDisabledSkills))+    when (provider == InteractiveCodex && kind == SkillKind && requested == Just SharedVisibility) $ do+      path <- System.Directory.canonicalizePath (assetPath </> "SKILL.md")+      entries <- readSkillEntries configFile >>= either throwIO pure+      disabled <- forM [p | (p, False) <- entries] System.Directory.canonicalizePath+      ownedPaths <- traverse (System.Directory.canonicalizePath . Text.unpack) oldDisabled+      when (path `elem` disabled && path `notElem` ownedPaths) $+        throwIO (KitCodexConfigUnusable configFile "a user-owned disabled entry hides this skill; remove that entry by hand before installing --shared")+    disablePaths <-+      if provider == InteractiveCodex && kind == SkillKind && requested == Just ToolOnlyVisibility+        then do+          unless ("SKILL.md" `elem` (sources ^. #files)) $+            throwIO (KitCodexConfigUnusable configFile "tool-only visibility on Codex needs a SKILL.md; use --shared")+          path <- System.Directory.canonicalizePath (assetPath </> "SKILL.md")+          pure [path]+        else pure []+    needed <- forM disablePaths $ \path -> do+      added <- checkDisabledSkill configFile path >>= either throwIO pure+      pure [Text.pack path | added || Text.pack path `elem` oldDisabled]+    let trackedDisabled = concat needed+    forM_ oldDisabled $ \path -> when (has _Just requested && Text.unpack path `notElem` disablePaths) $ do+      _ <- checkRemoveDisabledSkill configFile (Text.unpack path) >>= either throwIO pure+      pure ()+    pure+      ProviderVisibility+        { provider,+          baseDir,+          sidecarFile,+          previous,+          requested,+          source,+          links,+          oldLinks,+          configFile,+          disablePaths,+          trackedDisabled,+          oldDisabled+        }++-- Update reconciles copies already installed, including a surviving sidecar+-- with missing assets. It does not introduce a new unaccepted provider copy.+installedProvider :: KitConfig -> KitItem -> KitScope -> AgentAssetProvider -> IO Bool+installedProvider config item scope provider = do+  base <- providerAgentsBase config provider scope+  let kind = kitItemKind item+      name = itemName item+      asset = case kind of+        SkillKind -> skillTarget config provider base (Text.unpack name)+        AgentKind -> agentTarget config provider base (Text.unpack name)+  exists <- pathExists asset+  metadata <- doesFileExist (sidecarPath provider kind name base (sidecarFileName config))+  pure (exists || metadata)++applyVisibility :: ProviderVisibility -> IO ()+applyVisibility choice = when (has _Just (choice ^. #requested)) $ do+  result <- try @IOException . try @KitError $ do+    forM_ (choice ^. #links) applyLink+    forM_ (choice ^. #disablePaths) $ \path -> do+      addDisabledSkill (choice ^. #configFile) path >>= either throwIO (const (pure ()))+    forM_ (choice ^. #oldDisabled) $ \path ->+      unless (Text.unpack path `elem` (choice ^. #disablePaths)) $+        removeDisabledSkill (choice ^. #configFile) (Text.unpack path) >>= either throwIO (const (pure ()))+    forM_ (choice ^. #oldLinks) $ \link ->+      unless (any ((== link ^. #location) . view #location) (choice ^. #links)) $ do+        _ <- removeLink link+        pure ()+  case result of+    Left e -> throwIO (KitVisibilityNotApplied (Text.pack (show e)) (map (view #location) (choice ^. #links ++ choice ^. #oldLinks)))+    Right (Left err) ->+      throwIO+        ( KitVisibilityNotApplied+            (Text.pack (show err))+            (map (view #location) (choice ^. #links ++ choice ^. #oldLinks) ++ [choice ^. #configFile])+        )+    Right (Right ()) -> do+      meta <- readSidecar (choice ^. #sidecarFile)+      forM_ meta $ \current -> do+        let final =+              current+                & #sharedLinks+                .~ Just (map (Text.pack . view #location) (choice ^. #links))+                & #codexDisabledSkills+                .~ Just (choice ^. #trackedDisabled)+        when (current /= final) $+          executePlan [PlannedWrite (choice ^. #sidecarFile) (WriteBytes (encode final))]+            >>= either (\err -> throwIO (KitVisibilityNotApplied (Text.pack (show err)) [choice ^. #sidecarFile])) pure++repairSkippedVisibility :: [ProviderVisibility] -> IO ()+repairSkippedVisibility choices = do+  let writes =+        [ PlannedWrite+            (choice ^. #sidecarFile)+            ( WriteBytes+                ( encode+                    ( old+                        & #visibility+                        .~ (visibilityLabel <$> (choice ^. #requested))+                        & #visibilitySource+                        .~ (choice ^. #source)+                        & #sharedLinks+                        .~ Just (nub (map (Text.pack . view #location) (choice ^. #links ++ choice ^. #oldLinks)))+                        & #codexDisabledSkills+                        .~ Just (nub (choice ^. #trackedDisabled ++ choice ^. #oldDisabled))+                    )+                )+            )+        | choice <- choices,+          Just old <- [choice ^. #previous],+          has _Just (choice ^. #requested)+        ]   executePlan writes >>= either throwIO pure+  forM_ choices applyVisibility +doInstall :: KitConfig -> FilePath -> KitItem -> KitScope -> [ProviderVisibility] -> IO ()+doInstall config repoDir item scope choices = do+  writes <- planInstall config repoDir item scope choices+  executePlan writes >>= either throwIO pure+  forM_ choices applyVisibility+ -- | One installed asset: its name relative to the provider's installed --   root, where it goes, and the bytes to put there. data PlannedAsset = PlannedAsset@@ -463,8 +801,8 @@   }   deriving stock (Generic) -planInstall :: KitConfig -> FilePath -> KitItem -> KitScope -> IO [PlannedWrite]-planInstall config repoDir item scope = do+planInstall :: KitConfig -> FilePath -> KitItem -> KitScope -> [ProviderVisibility] -> IO [PlannedWrite]+planInstall config repoDir item _scope choices = do   safeName <- orThrow (KitUnsafeName (itemName item)) (safeItemName (itemName item))   sources <- either throwIO pure (itemSources item)   resolved <- resolveSources repoDir sources@@ -475,13 +813,23 @@       Right raw -> pure (rel, path, raw)   let upstreamHash = hashEntries [(rel, raw) | (rel, _, raw) <- contents]   fmap concat $-    forM (config ^. #providers) $ \provider -> do-      targetBase <- providerAgentsBase config provider scope+    forM choices $ \choice -> do+      let provider = choice ^. #provider+          targetBase = choice ^. #baseDir       assets <- providerAssets config provider targetBase safeName item contents       let installedNames = [Text.pack (asset ^. #relativeName) | asset <- assets]           installedDigest =             hashEntries [(asset ^. #relativeName, LBS.toStrict (asset ^. #bytes)) | asset <- assets]-      meta <- newSidecarMeta item upstreamHash installedNames installedDigest+      meta <-+        newSidecarMeta+          item+          upstreamHash+          installedNames+          installedDigest+          (visibilityLabel <$> (choice ^. #requested))+          (choice ^. #source)+          (nub (map (Text.pack . view #location) (choice ^. #links ++ choice ^. #oldLinks)))+          (nub (choice ^. #trackedDisabled ++ choice ^. #oldDisabled))       let assetWrites =             [ PlannedWrite {destination = asset ^. #target, content = WriteBytes (asset ^. #bytes)}             | asset <- assets@@ -564,7 +912,9 @@     providerBase <- providerAgentsBase config provider scope     skillExists <- doesDirectoryExist (skillTarget config provider providerBase safeName)     agentExists <- doesFileExist (agentTarget config provider providerBase safeName)-    pure (skillExists || agentExists)+    skillMetadata <- doesFileExist (sidecarPath provider SkillKind n providerBase (sidecarFileName config))+    agentMetadata <- doesFileExist (agentSidecarTarget config provider providerBase safeName)+    pure (skillExists || agentExists || skillMetadata || agentMetadata)   pure (or results)  -- | Run an action that may raise a 'KitError' and hand the caller a
+ src/Baikai/Kit/Json.hs view
@@ -0,0 +1,147 @@+-- | 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.Kit.Visibility (KitVisibility (..), visibilityLabel)+import Baikai.Prelude hiding ((.=))+import Data.Aeson (Value (Null), object, (.=))+import Data.List (sortOn)+import Data.Maybe (fromMaybe)++-- | 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) (visibilityLabel (fromMaybe ToolOnlyVisibility (entry ^. #visibility)))+             | entry <- manifest ^. #skills+             ]+               ++ [ itemValue AgentKind (entry ^. #name) (entry ^. #description) (entry ^. #version) (visibilityLabel (fromMaybe ToolOnlyVisibility (entry ^. #visibility)))+                  | entry <- manifest ^. #agents+                  ]+           )+    ]+  where+    itemValue :: KitItemKind -> Text -> Text -> Maybe Text -> Text -> Value+    itemValue kind n description version visibility =+      object+        [ "name" .= n,+          "kind" .= kindLabel kind,+          "description" .= description,+          "version" .= version,+          "visibility" .= visibility,+          "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),+          "requestedVisibility" .= fmap visibilityLabel (row ^. #requestedVisibility),+          "effectiveVisibility" .= visibilityLabel (row ^. #effectiveVisibility),+          "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/Link.hs view
@@ -0,0 +1,129 @@+-- | Shared Claude names are aliases to tool-owned copies, never copies.+module Baikai.Kit.Link+  ( SharedLink (..),+    claudeLinks,+    linkIsOurs,+    preflightLink,+    applyLink,+    removeLink,+    foreignOwner,+    pathExists,+    relativeLinkTarget,+  )+where++import Baikai.AgentAssets (agentTargetPath, skillTargetPath)+import Baikai.Interactive (InteractiveProvider (InteractiveClaude), InteractiveScope (InteractiveProjectScope))+import Baikai.Kit.Config (KitConfig, KitScope (..), providerAgentsBase, sharedClaudeBase)+import Baikai.Kit.Error (KitError (..))+import Baikai.Kit.Manifest (KitItemKind (..))+import Baikai.Prelude+import Control.Exception (IOException, throwIO, try)+import Control.Monad (unless, when)+import Data.List (find, isPrefixOf, isSuffixOf)+import Data.Text qualified as Text+import System.Directory+  ( canonicalizePath,+    createDirectoryIfMissing,+    createDirectoryLink,+    createFileLink,+    doesDirectoryExist,+    doesPathExist,+    getSymbolicLinkTarget,+    listDirectory,+    makeAbsolute,+    pathIsSymbolicLink,+    removeFile,+  )+import System.FilePath (joinPath, splitDirectories, takeDirectory, (</>))++data SharedLink = SharedLink+  { location :: !FilePath,+    target :: !FilePath,+    directory :: !Bool,+    relative :: !Bool+  }+  deriving stock (Generic, Show)++claudeLinks :: KitConfig -> KitScope -> KitItemKind -> Text -> Bool -> IO [SharedLink]+claudeLinks config scope kind n resources = do+  shared <- sharedClaudeBase config scope >>= makeAbsolute+  owned <- providerAgentsBase config InteractiveClaude scope >>= makeAbsolute+  let name = Text.unpack n+      path = case kind of+        SkillKind -> skillTargetPath InteractiveClaude InteractiveProjectScope name+        AgentKind -> agentTargetPath InteractiveClaude InteractiveProjectScope name+      paths =+        (path, kind == SkillKind)+          : [(takeDirectory path </> name, True) | kind == AgentKind, resources]+  pure [SharedLink (shared </> p) (owned </> p) isDir (scope == ProjectScope) | (p, isDir) <- paths]++pathExists :: FilePath -> IO Bool+pathExists path = do+  exists <- doesPathExist path+  linked <- either (const False) id <$> try @IOException (pathIsSymbolicLink path)+  pure (exists || linked)++linkIsOurs :: SharedLink -> IO Bool+linkIsOurs link = do+  linked <- either (const False) id <$> try @IOException (pathIsSymbolicLink (link ^. #location))+  if not linked+    then pure False+    else do+      raw <- getSymbolicLinkTarget (link ^. #location)+      actual <- canonicalizePath (takeDirectory (link ^. #location) </> raw)+      expected <- canonicalizePath (link ^. #target)+      pure (actual == expected)++preflightLink :: SharedLink -> IO ()+preflightLink link = do+  exists <- pathExists (link ^. #location)+  ours <- linkIsOurs link+  when (exists && not ours) $ do+    owner <- foreignOwner (link ^. #location)+    throwIO (KitSharedNameTaken (link ^. #location) owner)++applyLink :: SharedLink -> IO ()+applyLink link = do+  preflightLink link+  ours <- linkIsOurs link+  unless ours $ do+    createDirectoryIfMissing True (takeDirectory (link ^. #location))+    let target =+          if link ^. #relative+            then relativeLinkTarget (takeDirectory (link ^. #location)) (link ^. #target)+            else link ^. #target+    (if link ^. #directory then createDirectoryLink else createFileLink) target (link ^. #location)++removeLink :: SharedLink -> IO Bool+removeLink link = do+  ours <- linkIsOurs link+  when ours (removeFile (link ^. #location))+  pure ours++-- | A lexical relative target, including sibling directories.+relativeLinkTarget :: FilePath -> FilePath -> FilePath+relativeLinkTarget parent target = go (splitDirectories parent) (splitDirectories target)+  where+    go (a : as) (b : bs) | a == b = go as bs+    go as bs = joinPath (replicate (length as) ".." ++ bs)++foreignOwner :: FilePath -> IO (Maybe Text)+foreignOwner path = do+  linked <- either (const False) id <$> try @IOException (pathIsSymbolicLink path)+  if linked+    then do+      raw <- getSymbolicLinkTarget path+      absolute <- canonicalizePath (takeDirectory path </> raw)+      pure (ownerOf (splitDirectories absolute))+    else do+      isDir <- doesDirectoryExist path+      files <- if isDir then listDirectory path else pure []+      pure $ Text.pack . drop 1 . takeWhileSuffix <$> find sidecar files+  where+    sidecar f = "." `isPrefixOf` f && "-kit.json" `isSuffixOf` f+    takeWhileSuffix f = take (length f - length ("-kit.json" :: String)) f+    ownerOf (".config" : tool : "agents" : _) = Just (Text.pack tool)+    ownerOf (tool : "agents" : _) | "." `isPrefixOf` tool = Just (Text.pack (drop 1 tool))+    ownerOf (_ : rest) = ownerOf rest+    ownerOf [] = Nothing
src/Baikai/Kit/Manifest.hs view
@@ -9,6 +9,7 @@     itemName,     itemSources,     itemVersion,+    itemVisibility,     kitItemKind,     kindLabel,     supportedManifestVersions,@@ -17,6 +18,7 @@  import Baikai.Kit.Error (KitError (..)) import Baikai.Kit.Path (safeItemName, safeRelativePath)+import Baikai.Kit.Visibility (KitVisibility (..)) import Baikai.Prelude import Data.Bifunctor (first) import System.FilePath (takeDirectory, takeFileName)@@ -34,7 +36,8 @@     description :: !Text,     version :: !(Maybe Text),     path :: !Text,-    files :: ![Text]+    files :: ![Text],+    visibility :: !(Maybe KitVisibility)   }   deriving stock (Generic, Show)   deriving anyclass (FromJSON)@@ -44,7 +47,8 @@     description :: !Text,     version :: !(Maybe Text),     path :: !Text,-    files :: !(Maybe [Text])+    files :: !(Maybe [Text]),+    visibility :: !(Maybe KitVisibility)   }   deriving stock (Generic, Show)   deriving anyclass (FromJSON)@@ -122,3 +126,7 @@ itemVersion :: KitItem -> Maybe Text itemVersion (KitSkillItem entry) = entry ^. #version itemVersion (KitAgentItem entry) = entry ^. #version++itemVisibility :: KitItem -> KitVisibility+itemVisibility (KitSkillItem entry) = maybe ToolOnlyVisibility id (entry ^. #visibility)+itemVisibility (KitAgentItem entry) = maybe ToolOnlyVisibility id (entry ^. #visibility)
src/Baikai/Kit/Session.hs view
@@ -1,14 +1,42 @@ module Baikai.Kit.Session   ( agentDirsForSession,+    codexSessionArgs,   ) where -import Baikai.Kit.Config (KitConfig, projectAgentsDir, userAgentsDir)-import Control.Monad (filterM)-import System.Directory (doesDirectoryExist)+import Baikai.Interactive (InteractiveProvider (InteractiveCodex))+import Baikai.Kit.CodexConfig (enableSkillsArgs)+import Baikai.Kit.Config (KitConfig, KitScope (..), projectAgentsDir, providerAgentsBase, sidecarFileName, userAgentsDir)+import Baikai.Kit.Sidecar (readSidecar)+import Baikai.Prelude+import Control.Monad (filterM, forM)+import Data.List (nub, sort)+import Data.Maybe (fromMaybe)+import Data.Text qualified as Text+import System.Directory (canonicalizePath, doesDirectoryExist, listDirectory)+import System.FilePath ((</>))  agentDirsForSession :: KitConfig -> IO [FilePath] agentDirsForSession config = do   userDir <- userAgentsDir config   projectDir <- projectAgentsDir config   filterM doesDirectoryExist [userDir, projectDir]++-- | Add these to a tool's Codex launch request's extraArgs. Other providers+-- use agentDirsForSession instead. Only this tool's sidecars contribute.+codexSessionArgs :: KitConfig -> IO [Text]+codexSessionArgs config+  | InteractiveCodex `notElem` (config ^. #providers) = pure []+  | otherwise = do+      paths <- fmap concat . forM [UserScope, ProjectScope] $ \scope -> do+        base <- providerAgentsBase config InteractiveCodex scope+        let root = base </> ".agents/skills"+        exists <- doesDirectoryExist root+        names <- if exists then sort <$> listDirectory root else pure []+        fmap concat . forM names $ \name -> do+          meta <- readSidecar (root </> name </> Text.unpack (sidecarFileName config))+          let tracked = map Text.unpack (fromMaybe [] (meta >>= (^. #codexDisabledSkills)))+              hidden = [root </> name </> "SKILL.md" | (meta >>= (^. #visibility)) == Just "tool-only"]+          pure (hidden ++ tracked)+      canonical <- traverse canonicalizePath paths+      pure (enableSkillsArgs (nub canonical))
src/Baikai/Kit/Sidecar.hs view
@@ -45,7 +45,11 @@     hash :: !Text,     installedAt :: !Text,     installedFiles :: !(Maybe [Text]),-    installedHash :: !(Maybe Text)+    installedHash :: !(Maybe Text),+    visibility :: !(Maybe Text),+    visibilitySource :: !(Maybe Text),+    sharedLinks :: !(Maybe [Text]),+    codexDisabledSkills :: !(Maybe [Text])   }   deriving stock (Eq, Generic, Show)   deriving anyclass (FromJSON, ToJSON)@@ -114,8 +118,8 @@ -- | Build the sidecar for one provider: the upstream content hash, the --   names this install writes for that provider, and the hash of the --   bytes it writes.-newSidecarMeta :: KitItem -> Text -> [Text] -> Text -> IO SidecarMeta-newSidecarMeta item hashStr writtenFiles writtenHash = do+newSidecarMeta :: KitItem -> Text -> [Text] -> Text -> Maybe Text -> Maybe Text -> [Text] -> [Text] -> IO SidecarMeta+newSidecarMeta item hashStr writtenFiles writtenHash visibility visibilitySource sharedLinks codexDisabledSkills = do   now <- getCurrentTime   let stamp = Text.pack (formatTime defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" now)   pure@@ -126,5 +130,9 @@         hash = hashStr,         installedAt = stamp,         installedFiles = Just writtenFiles,-        installedHash = Just writtenHash+        installedHash = Just writtenHash,+        visibility,+        visibilitySource,+        sharedLinks = Just sharedLinks,+        codexDisabledSkills = Just codexDisabledSkills       }
src/Baikai/Kit/Status.hs view
@@ -1,41 +1,62 @@ module Baikai.Kit.Status-  ( KitState (..),+  ( KitCondition (..),+    InstalledCopy (..),     StatusReport (..),     StatusRow (..),     UpstreamAvailability (..),     classify,     collectStatus,+    installedCopies,     kitStatus,-    renderState,+    conditionLabel,+    renderConditions,     renderStatusTable,   ) where  import Baikai.AgentAssets (AgentAssetProvider, agentTargetPath, skillTargetPath)-import Baikai.Interactive (InteractiveScope (InteractiveProjectScope))+import Baikai.Interactive (InteractiveProvider (InteractiveCodex), InteractiveScope (InteractiveProjectScope))+import Baikai.Kit.CodexConfig (codexConfigPath, readSkillEntries) 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, checkVisibilityWithEntries, 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.Kit.Visibility (KitVisibility (..), visibilityLabel) 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+  | KitVisibilityBroken+  deriving stock (Eq, Ord, Show, Enum, Bounded)  -- | Whether the cached upstream could be consulted for this report. data UpstreamAvailability@@ -63,35 +84,46 @@     providers :: !Text,     installedVersion :: !(Maybe Text),     latestVersion :: !(Maybe Text),-    state :: !KitState+    -- | Sorted and duplicate-free; empty means up to date.+    conditions :: ![KitCondition],+    requestedVisibility :: !(Maybe KitVisibility),+    effectiveVisibility :: !KitVisibility   }   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"+  KitVisibilityBroken -> "visibility-broken" -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@@ -121,15 +153,32 @@   -- 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+  entries <-+    if InteractiveCodex `elem` (config ^. #providers)+      then codexConfigPath >>= readSkillEntries+      else pure (Right [])   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 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]+      visibility <- checkVisibilityWithEntries entries config provider scope scannedKind itemName'+      let requestedVisibility = either (const Nothing) (view #requested) visibility+          effectiveVisibility = either (const SharedVisibility) (view #effective) visibility+          visibilityConditions = [KitVisibilityBroken | either (const True) (not . null . view #broken) visibility]       pure         StatusRow           { name = itemName',@@ -138,9 +187,45 @@             providers = providerLabel provider,             installedVersion = mSidecar >>= (^. #version),             latestVersion = mItem >>= itemVersion,-            state = state'+            conditions = sort (nub (upstreamConditions ++ localConditions ++ visibilityConditions)),+            requestedVisibility,+            effectiveVisibility           } +-- | 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,@@ -191,13 +276,14 @@ -- | 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)+renderStatusTable rows = Text.intercalate "\n" (hdr : map printRow displayRows ++ legacyNote)   where     displayRows = aggregateStatusRows rows     nameW = colWidth "NAME" (^. #name)     kindW = colWidth "TYPE" (^. #kind)     scopeW = colWidth "SCOPE" (^. #scope)     providersW = colWidth "PROVIDERS" (^. #providers)+    visibilityW = colWidth "VISIBILITY" renderVisibility     instW = colWidth "INSTALLED" (renderMVer . view #installedVersion)     latW = colWidth "LATEST" (renderMVer . view #latestVersion)     hdr =@@ -205,6 +291,7 @@         <> Text.justifyLeft (kindW + 2) ' ' "TYPE"         <> Text.justifyLeft (scopeW + 2) ' ' "SCOPE"         <> Text.justifyLeft (providersW + 2) ' ' "PROVIDERS"+        <> Text.justifyLeft (visibilityW + 2) ' ' "VISIBILITY"         <> Text.justifyLeft (instW + 2) ' ' "INSTALLED"         <> Text.justifyLeft (latW + 2) ' ' "LATEST"         <> "STATE"@@ -212,6 +299,24 @@     colWidth colTitle f =       maximum (Text.length colTitle : map (Text.length . f) displayRows) +    legacyNote =+      if any legacy rows+        then+          [ "",+            "Note: Codex skills installed before baikai-kit 0.4.0.0 are visible to every Codex session.",+            "Reinstall one with 'kit install NAME --tool-only' to limit it to this tool's sessions."+          ]+        else []+    legacy row =+      row ^. #kind == "skill"+        && row ^. #providers == "codex"+        && isNothing (row ^. #requestedVisibility)+        && row ^. #effectiveVisibility == SharedVisibility+    renderVisibility row =+      visibilityLabel (row ^. #effectiveVisibility)+        <> case row ^. #requestedVisibility of+          Just requested | requested /= row ^. #effectiveVisibility -> " (requested " <> visibilityLabel requested <> ")"+          _ -> ""     renderMVer = fromMaybe "-"      printRow row =@@ -219,9 +324,10 @@         <> Text.justifyLeft (kindW + 2) ' ' (row ^. #kind)         <> Text.justifyLeft (scopeW + 2) ' ' (row ^. #scope)         <> Text.justifyLeft (providersW + 2) ' ' (row ^. #providers)+        <> Text.justifyLeft (visibilityW + 2) ' ' (renderVisibility row)         <> 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 +340,9 @@         row ^. #scope,         row ^. #installedVersion,         row ^. #latestVersion,-        row ^. #state+        row ^. #conditions,+        row ^. #requestedVisibility,+        row ^. #effectiveVisibility       )     sameKey a b = rowKey a == rowKey b     summarize groupRows@(firstRow : _) =
+ src/Baikai/Kit/Visibility.hs view
@@ -0,0 +1,30 @@+-- | Visibility is independent of user/project scope.+module Baikai.Kit.Visibility+  ( KitVisibility (..),+    VisibilitySource (..),+    visibilityLabel,+    parseVisibility,+  )+where++import Baikai.Prelude+import Data.Aeson (withText)+import Data.Text qualified as Text++data KitVisibility = ToolOnlyVisibility | SharedVisibility+  deriving stock (Eq, Ord, Show, Enum, Bounded)++data VisibilitySource = FromManifest | FromInstallFlag+  deriving stock (Eq, Show)++visibilityLabel :: KitVisibility -> Text+visibilityLabel ToolOnlyVisibility = "tool-only"+visibilityLabel SharedVisibility = "shared"++parseVisibility :: Text -> Either Text KitVisibility+parseVisibility "tool-only" = Right ToolOnlyVisibility+parseVisibility "shared" = Right SharedVisibility+parseVisibility other = Left ("unknown visibility '" <> other <> "'; expected tool-only or shared")++instance FromJSON KitVisibility where+  parseJSON = withText "KitVisibility" (either (fail . Text.unpack) pure . parseVisibility)
test/Main.hs view
@@ -5,804 +5,1858 @@ import Baikai.Interactive (InteractiveProvider (InteractiveClaude, InteractiveCodex)) import Baikai.Kit   ( AgentEntry (..),-    KitCommand (..),-    KitConfig (..),-    KitError (..),-    KitItem (..),-    KitItemKind (..),-    KitManifest (..),-    KitScope (UserScope),-    KitState (..),-    OverwritePolicy (..),-    PlannedWrite (..),-    PullResult (..),-    RemovalOutcome (..),-    SidecarMeta (..),-    SkillEntry (..),-    UpstreamAvailability (..),-    WriteContent (..),-    classify,-    collectStatus,-    computeKitHash,-    executePlanWith,-    installItem,-    kitStatus,-    loadManifest,-    pullKitRepo,-    readSidecar,-    reinstallPresent,-    renderState,-    renderUninstallReport,-    runKit,-    safeItemName,-    safeRelativePath,-    safeSourcePath,-    sidecarFileName,-    sidecarPath,-    stripYamlFrontmatter,-    uninstallItem,-    updateKit,-  )-import Baikai.Prelude-import Control.Exception (finally, try)-import Data.Aeson qualified as Aeson-import Data.ByteString qualified as BS-import Data.List (find, isSuffixOf)-import Data.Text qualified as Text-import Data.Text.Encoding qualified as Text.Encoding-import System.Directory-  ( createDirectoryIfMissing,-    createDirectoryLink,-    doesDirectoryExist,-    doesFileExist,-    doesPathExist,-    listDirectory,-    removeDirectoryRecursive,-    removeFile,-    renameFile,-  )-import System.Environment (lookupEnv, setEnv, unsetEnv)-import System.Exit (ExitCode (..))-import System.FilePath (takeDirectory, (</>))-import System.IO.Temp (withSystemTempDirectory)-import Test.Tasty (TestTree, defaultMain, localOption, testGroup)-import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))-import Test.Tasty.Runners (NumThreads (NumThreads))--main :: IO ()-main =-  defaultMain $-    localOption (NumThreads 1) $-      testGroup-        "baikai-kit"-        [ manifestTests,-          hashTests,-          pathSafetyTests,-          symlinkSafetyTests,-          frontmatterTests,-          classifyTests,-          statusFilesystemTests,-          installRoundTripTests,-          typedErrorTests,-          installFidelityTests-        ]--manifestTests :: TestTree-manifestTests =-  testGroup-    "Manifest backward compatibility"-    [ fixtureCase "mori-kit.json" 2 4 0,-      fixtureCase "rei-kit.json" 1 9 1,-      fixtureCase "seihou-kit.json" 1 2 0-    ]--fixtureCase :: FilePath -> Int -> Int -> Int -> TestTree-fixtureCase file expectedVersion expectedSkills expectedAgents =-  testCase (file <> " decodes") $ do-    manifest <- decodeFixture file-    (manifest ^. #version) @?= expectedVersion-    length (manifest ^. #skills) @?= expectedSkills-    length (manifest ^. #agents) @?= expectedAgents--hashTests :: TestTree-hashTests =-  testGroup-    "Hash"-    [ testCase "computeKitHash is deterministic regardless of input order" $-        withSystemTempDirectory "baikai-kit-hash" $ \dir -> do-          BS.writeFile (dir </> "a.md") "alpha"-          BS.writeFile (dir </> "b.md") "beta"-          BS.writeFile (dir </> "c.md") "gamma"-          h1 <- assertRight =<< computeKitHash dir "." ["a.md", "b.md", "c.md"]-          h2 <- assertRight =<< computeKitHash dir "." ["c.md", "a.md", "b.md"]-          h1 @?= h2-          assertBool "hash should carry sha256 prefix" ("sha256:" `Text.isPrefixOf` h1),-      testCase "computeKitHash changes when file content changes" $-        withSystemTempDirectory "baikai-kit-hash-mut" $ \dir -> do-          BS.writeFile (dir </> "a.md") "alpha"-          before <- assertRight =<< computeKitHash dir "." ["a.md"]-          BS.writeFile (dir </> "a.md") "alpha-modified"-          after <- assertRight =<< computeKitHash dir "." ["a.md"]-          assertBool "hashes must differ after content change" (before /= after)-    ]--classifyTests :: TestTree-classifyTests =-  testGroup-    "Status.classify"-    [ testCase "no sidecar => unknown" $-        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,-      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" $-        classify-          (Just (mkSidecar (Just "1.0") "h1"))-          (Just (mkSkillItem "foo" (Just "2.0")))-          (Just "h2")-          @?= KitDirtyOutdated,-      testCase "hash mismatch => dirty" $-        classify-          (Just (mkSidecar (Just "1.0") "h1"))-          (Just (mkSkillItem "foo" (Just "1.0")))-          (Just "h2")-          @?= KitDirty,-      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-    ]--pathSafetyTests :: TestTree-pathSafetyTests =-  testGroup-    "Path safety"-    [ testCase "safeRelativePath accepts and normalises harmless paths" $ do-        safeRelativePath "SKILL.md" @?= Right "SKILL.md"-        safeRelativePath "skills/review" @?= Right "skills/review"-        safeRelativePath "a/./b" @?= Right ("a" </> "b"),-      testCase "safeRelativePath rejects zip-slip, absolute paths, backslashes, and NUL" $ do-        mapM_-          (assertLeft . safeRelativePath)-          ["", "/etc/passwd", "../x", "a/../../x", "..", "a/..", "a\\..\\b", "a\0b"],-      testCase "safeItemName rejects multi-component and hidden names" $ do-        safeItemName "reviewer" @?= Right "reviewer"-        mapM_ (assertLeft . safeItemName) ["a/b", ".", ".hidden"],-      testCase "install refuses a manifest file path that escapes the install root" $-        withPreparedKitHome $ \home cache -> do-          BS.writeFile (cache </> "kit.json") maliciousManifestJson-          result <- installItem testConfig "evil" UserScope-          assertKitError "KitUnsafePath" isUnsafePath result-          assertFileMissing (takeDirectory home </> "escape.txt")-          exitResult <- try @ExitCode (runKit testConfig (KitInstall "evil" UserScope))-          exitResult @?= Left (ExitFailure 1),-      testCase "uninstall refuses a traversal name" $-        withPreparedKitHome $ \home _cache -> do-          let victim = home </> "victim"-          createDirectoryIfMissing True victim-          result <- uninstallItem testConfig "../victim" UserScope-          assertKitError "KitUnsafeName" isUnsafeName result-          assertDirectoryExists victim-          exitResult <- try @ExitCode (runKit testConfig (KitUninstall "../victim" UserScope))-          exitResult @?= Left (ExitFailure 1)-          assertDirectoryExists victim-    ]--symlinkSafetyTests :: TestTree-symlinkSafetyTests =-  testGroup-    "Symlink safety"-    [ testCase "safeSourcePath refuses a symlinked component" $-        withPreparedKitHome $ \home cache -> do-          plantSymlinkedSource home cache-          refused <- safeSourcePath cache ("skills" </> "demo" </> "sub" </> "secret.txt")-          refused @?= Left (KitSourceSymlink (cache </> "skills" </> "demo" </> "sub"))-          absolute <- safeSourcePath cache "/etc/passwd"-          case absolute of-            Left (KitUnsafePath _ _) -> pure ()-            other -> assertFailure ("expected KitUnsafePath, got " <> show other)-          plain <- safeSourcePath cache ("skills" </> "demo" </> "SKILL.md")-          plain @?= Right (cache </> "skills" </> "demo" </> "SKILL.md"),-      testCase "computeKitHash refuses a symlinked source" $-        withPreparedKitHome $ \home cache -> do-          plantSymlinkedSource home cache-          hashed <- computeKitHash cache ("skills" </> "demo") ["SKILL.md", "sub" </> "secret.txt"]-          hashed @?= Left (KitSourceSymlink (cache </> "skills" </> "demo" </> "sub")),-      testCase "install refuses a symlinked source and writes nothing" $-        withPreparedKitHome $ \home cache -> do-          plantSymlinkedSource home cache-          let claudeSkill = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"-          result <- installItem testConfig "demo" UserScope-          assertKitError "KitSourceSymlink" isSourceSymlink result-          assertFileMissing (claudeSkill </> "sub" </> "secret.txt")-          assertFileMissing (claudeSkill </> "SKILL.md"),-      testCase "status reports refused when upstream lists a symlinked source" $-        withPreparedKitHome $ \home cache -> do-          _ <- assertRight =<< installItem testConfig "demo" UserScope-          plantSymlinkedSource home cache-          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"-    ]--frontmatterTests :: TestTree-frontmatterTests =-  testGroup-    "Frontmatter"-    [ testCase "stripYamlFrontmatter handles LF, CRLF, and final delimiter without newline" $ do-        stripYamlFrontmatter "---\nname: x\n---\nBody.\n" @?= "Body.\n"-        stripYamlFrontmatter "---\r\nname: x\r\n---\r\nBody.\r\n" @?= "Body.\n"-        stripYamlFrontmatter "---\nname: x\n---" @?= "",-      testCase "stripYamlFrontmatter leaves non-frontmatter and unterminated blocks unchanged" $ do-        stripYamlFrontmatter "Body.\n" @?= "Body.\n"-        stripYamlFrontmatter "---\nname: x\nBody.\n" @?= "---\nname: x\nBody.\n",-      testCase "stripYamlFrontmatter normalises line endings on every branch" $ do-        stripYamlFrontmatter "Body.\r\n" @?= "Body.\n"-        stripYamlFrontmatter "---\r\nname: x\r\nBody.\r\n" @?= "---\nname: x\nBody.\n"-    ]--statusFilesystemTests :: TestTree-statusFilesystemTests =-  testGroup-    "Status filesystem"-    [ testCase "sidecarPath drops any agent target extension" $-        withPreparedKitHome $ \home _cache -> do-          let claudeBase = home </> ".config" </> "testkit" </> "agents"-              codexBase = home-              sidecar = sidecarFileName testConfig-          sidecarPath InteractiveClaude AgentKind "reviewer" claudeBase sidecar-            @?= claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"-          sidecarPath InteractiveCodex AgentKind "reviewer" codexBase sidecar-            @?= codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json",-      testCase "delisted installed item keeps sidecar version in status" $-        withPreparedKitHome $ \_home cache -> do-          _ <- assertRight =<< installItem testConfig "demo" UserScope-          BS.writeFile (cache </> "kit.json") manifestWithoutDemoJson-          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 ^. #installedVersion @?= Just "0.1.0") demoRows,-      testCase "version and cached hash drift reports dirty+outdated" $-        withPreparedKitHome $ \_home cache -> do-          _ <- assertRight =<< installItem testConfig "demo" UserScope-          BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "changed instructions\n"-          BS.writeFile (cache </> "kit.json") manifestWithDemoVersionJson-          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-    ]--installRoundTripTests :: TestTree-installRoundTripTests =-  testGroup-    "Install"-    [ testCase "skill and agent round-trip through Claude and Codex layouts with sidecars" $-        withPreparedKitHome $ \home _cache -> do-          let config = testConfig-              claudeBase = home </> ".config" </> "testkit" </> "agents"-              codexBase = home-              claudeSkill = claudeBase </> ".claude" </> "skills" </> "demo"-              codexSkill = codexBase </> ".agents" </> "skills" </> "demo"-              claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"-              codexAgent = codexBase </> ".codex" </> "agents" </> "reviewer.toml"-              claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"-              codexAgentSidecar = codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json"-          _ <- assertRight =<< installItem config "demo" UserScope-          assertFileExists (claudeSkill </> "SKILL.md")-          assertFileExists (codexSkill </> "SKILL.md")-          assertFileExists (claudeSkill </> ".testkit-kit.json")-          meta <- readSidecar (claudeSkill </> ".testkit-kit.json")-          case meta of-            Just sidecar -> do-              (sidecar ^. #name) @?= ("demo" :: Text)-              (sidecar ^. #kind) @?= ("skill" :: Text)-            Nothing -> assertFailure "expected a skill sidecar"-          _ <- assertRight =<< uninstallItem config "demo" UserScope-          assertDirectoryMissing claudeSkill-          assertDirectoryMissing codexSkill-          _ <- assertRight =<< installItem config "reviewer" UserScope-          assertFileExists claudeAgent-          assertFileExists codexAgent-          assertFileExists claudeAgentSidecar-          assertFileExists codexAgentSidecar-          toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent-          assertBool "Codex agent TOML should contain developer instructions" ("developer_instructions" `Text.isInfixOf` toml)-          _ <- assertRight =<< uninstallItem config "reviewer" UserScope-          assertFileMissing claudeAgent-          assertFileMissing codexAgent-          assertFileMissing claudeAgentSidecar-          assertFileMissing codexAgentSidecar,-      testCase "Codex TOML strips CRLF YAML frontmatter" $-        withPreparedKitHome $ \home cache -> do-          let codexAgent = home </> ".codex" </> "agents" </> "reviewer.toml"-          BS.writeFile (cache </> "agents" </> "reviewer.md") "---\r\nname: reviewer\r\n---\r\nReview carefully.\r\n"-          _ <- assertRight =<< installItem testConfig "reviewer" UserScope-          toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent-          assertBool "frontmatter name should be stripped" (not ("name: reviewer" `Text.isInfixOf` toml))-          assertBool "CR characters should be stripped" (not ("\r" `Text.isInfixOf` toml)),-      testCase "failed provider write rolls back all staged writes" $-        withPreparedKitHome $ \home _cache -> do-          let claudeBase = home </> ".config" </> "testkit" </> "agents"-              claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"-              claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"-          BS.writeFile (home </> ".codex") ""-          result <- installItem testConfig "reviewer" UserScope-          assertKitError "KitWriteFailed" isWriteFailed result-          assertFileMissing claudeAgent-          assertFileMissing claudeAgentSidecar-          tmpFiles <- findFilesWithSuffix home ".baikai-kit-tmp"-          tmpFiles @?= [],-      testCase "renderUninstallReport names actual assets and stale metadata" $ do-        renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False]-          @?= "Uninstalled skill 'demo' from user scope (claude)."-        renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False, RemovalOutcome InteractiveCodex True False False]-          @?= "Uninstalled skill 'demo' from user scope (claude,codex)."-        renderUninstallReport "reviewer" UserScope [RemovalOutcome InteractiveClaude False False True]-          @?= "Removed stale kit metadata for 'reviewer' from user scope."-        renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude False False False]-          @?= "'demo' is not installed in user scope.",-      testCase "uninstallItem reports per-provider removals" $-        withPreparedKitHome $ \home _cache -> do-          let codexSkill = home </> ".agents" </> "skills" </> "demo"-          _ <- assertRight =<< installItem testConfig "demo" UserScope-          removeDirectoryRecursive codexSkill-          outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope-          let claudeOutcome = findOutcome InteractiveClaude outcomes-              codexOutcome = findOutcome InteractiveCodex outcomes-          view #skillRemoved claudeOutcome @?= True-          view #skillRemoved codexOutcome @?= False-          view #agentRemoved codexOutcome @?= False-          view #sidecarRemoved codexOutcome @?= False,-      testCase "pull failure is returned as a typed error" $-        withPreparedKitHome $ \_home cache -> do-          result <- pullKitRepo testConfig cache-          case result of-            PullFailed _ -> pure ()-            PullSucceeded -> assertFailure "expected fake .git cache pull to fail"-          updateResult <- updateKit testConfig Nothing KeepLocalEdits-          assertKitError "KitPullFailed" isPullFailed updateResult-    ]--typedErrorTests :: TestTree-typedErrorTests =-  testGroup-    "Typed errors"-    [ testCase "loadManifest returns typed errors" $-        withSystemTempDirectory "baikai-kit-manifest" $ \dir -> do-          missing <- loadManifest dir-          assertKitError "KitManifestMissing" isManifestMissing missing-          BS.writeFile (dir </> "kit.json") "{"-          invalid <- loadManifest dir-          assertKitError "KitManifestInvalid" isManifestInvalid invalid,-      testCase "installItem returns KitItemNotFound" $-        withPreparedKitHome $ \_home _cache -> do-          result <- installItem testConfig "nope" UserScope-          assertKitError "KitItemNotFound" (== KitItemNotFound "nope") result,-      testCase "kit status offline on a fresh HOME exits 0" $-        withSystemTempDirectory "baikai-kit-offline" $ \tmp -> do-          oldHome <- lookupEnv "HOME"-          let home = tmp </> "home"-              config = testConfig & #repoUrl .~ "file:///nonexistent-kit"-          createDirectoryIfMissing True home-          setEnv "HOME" home-          flip finally (restoreHome oldHome) $ do-            exitResult <- try @ExitCode (runKit config KitStatus)-            exitResult @?= Right ()-            report <- kitStatus config-            (report ^. #rows) @?= []-            case report ^. #upstream of-              UpstreamUnavailable (KitCloneFailed _ _) -> pure ()-              other -> assertFailure ("expected an unavailable upstream, got " <> show other)-    ]--installFidelityTests :: TestTree-installFidelityTests =-  testGroup-    "Install fidelity"-    [ testCase "multi-file agent installs every listed file" $-        withPreparedKitHome $ \home cache -> do-          plantMultiFileAgent cache-          let claudeBase = home </> ".config" </> "testkit" </> "agents"-              claudeAgents = claudeBase </> ".claude" </> "agents"-              codexAgents = home </> ".codex" </> "agents"-          _ <- assertRight =<< installItem testConfig "reviewer" UserScope-          assertFileExists (claudeAgents </> "reviewer.md")-          assertFileExists (claudeAgents </> "reviewer" </> "guide.md")-          assertFileExists (codexAgents </> "reviewer.toml")-          assertFileExists (codexAgents </> "reviewer" </> "guide.md")-          meta <- readSidecar (claudeAgents </> "reviewer.testkit-kit.json")-          case meta of-            Just sidecar -> (sidecar ^. #installedFiles) @?= Just ["reviewer.md", "reviewer" <> "/" <> "guide.md"]-            Nothing -> assertFailure "expected an agent sidecar"-          _ <- assertRight =<< uninstallItem testConfig "reviewer" UserScope-          assertDirectoryMissing (claudeAgents </> "reviewer")-          assertDirectoryMissing (codexAgents </> "reviewer"),-      testCase "phase-two failure restores the previous files" $-        withSystemTempDirectory "baikai-kit-journal" $ \dir -> do-          BS.writeFile (dir </> "a.txt") "old"-          let writes =-                [ PlannedWrite {destination = dir </> "a.txt", content = WriteBytes "new"},-                  PlannedWrite {destination = dir </> "b.txt", content = WriteBytes "new"}-                ]-              failingRename temp dest-                | "b.txt" `isSuffixOf` dest = do-                    takeDirectory temp @?= dir-                    assertBool "temporary should keep the tmp suffix" (".baikai-kit-tmp" `isSuffixOf` temp)-                    assertBool "temporary name should be unique" (temp /= dest <> ".baikai-kit-tmp")-                    ioError (userError "boom")-                | otherwise = renameFile temp dest-          result <- executePlanWith failingRename writes-          case result of-            Left (KitWriteFailed _ restored broken) -> do-              restored @?= [dir </> "a.txt"]-              broken @?= []-            other -> assertFailure ("expected KitWriteFailed, got " <> show other)-          kept <- BS.readFile (dir </> "a.txt")-          kept @?= "old"-          assertFileMissing (dir </> "b.txt")-          tmpFiles <- findFilesWithSuffix dir ".baikai-kit-tmp"-          tmpFiles @?= []-          bakFiles <- findFilesWithSuffix dir ".baikai-kit-bak"-          bakFiles @?= [],-      testCase "destination directory is refused before any write" $-        withPreparedKitHome $ \home _cache -> do-          let claudeAgents = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "agents"-          createDirectoryIfMissing True (claudeAgents </> "reviewer.md")-          result <- installItem testConfig "reviewer" UserScope-          assertKitError "KitWriteFailed" isWriteFailed result-          assertFileMissing (home </> ".codex" </> "agents" </> "reviewer.toml"),-      testCase "unsupported manifest version is refused" $-        withPreparedKitHome $ \_home cache -> do-          BS.writeFile (cache </> "kit.json") manifestWithUnsupportedVersionJson-          loaded <- loadManifest cache-          case loaded of-            Left (KitManifestVersionUnsupported _ 99) -> pure ()-            other -> assertFailure ("expected KitManifestVersionUnsupported 99, got " <> show other)-          installed <- installItem testConfig "demo" UserScope-          assertKitError "KitManifestVersionUnsupported" isVersionUnsupported installed,-      testCase "a sidecar written before the installed-file fields still decodes" $-        case Aeson.eitherDecodeStrict' legacySidecarJson :: Either String SidecarMeta of-          Right meta -> do-            (meta ^. #name) @?= ("demo" :: Text)-            (meta ^. #installedFiles) @?= Nothing-            (meta ^. #installedHash) @?= Nothing-          Left err -> assertFailure ("expected a legacy sidecar to decode: " <> err),-      testCase "update skips locally modified items unless forced" $-        withPreparedKitHome $ \home cache -> do-          let claudeSkill = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"-              upstream = cache </> "skills" </> "demo" </> "SKILL.md"-          _ <- assertRight =<< installItem testConfig "demo" UserScope-          BS.writeFile (claudeSkill </> "SKILL.md") "my edits"-          BS.writeFile upstream "new upstream"-          manifest <- assertRight =<< loadManifest cache-          kept <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits-          (kept ^. #skipped) @?= [("demo", UserScope)]-          (kept ^. #updated) @?= []-          mine <- BS.readFile (claudeSkill </> "SKILL.md")-          mine @?= "my edits"-          forced <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") OverwriteLocalEdits-          (forced ^. #updated) @?= [("demo", UserScope)]-          (forced ^. #skipped) @?= []-          fresh <- BS.readFile (claudeSkill </> "SKILL.md")-          fresh @?= "new upstream"-          -- A sidecar from before this release records no installed hash,-          -- so the item is reinstalled without the check.-          BS.writeFile (claudeSkill </> ".testkit-kit.json") legacySidecarJson-          BS.writeFile (claudeSkill </> "SKILL.md") "my edits again"-          legacy <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits-          (legacy ^. #updated) @?= [("demo", UserScope)]-          (legacy ^. #skipped) @?= [],-      testCase "a write failure during update is returned, not thrown" $-        withPreparedKitHome $ \home _cache -> do-          let claudeAgents = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "agents"-              cache = home </> ".cache" </> "testkit" </> "kit"-          _ <- assertRight =<< installItem testConfig "reviewer" UserScope-          removeFile (claudeAgents </> "reviewer.md")-          createDirectoryIfMissing True (claudeAgents </> "reviewer.md")-          manifest <- assertRight =<< loadManifest cache-          result <- reinstallPresent testConfig cache manifest (Just "reviewer") OverwriteLocalEdits-          assertKitError "KitWriteFailed" isWriteFailed result-    ]--decodeFixture :: FilePath -> IO KitManifest-decodeFixture file = do-  bytes <- BS.readFile ("test/fixtures" </> file)-  case Aeson.eitherDecodeStrict' bytes of-    Right manifest -> pure manifest-    Left err -> assertFailure ("failed to decode " <> file <> ": " <> err)--mkSidecar :: Maybe Text -> Text -> SidecarMeta-mkSidecar mVersion h =-  SidecarMeta-    { name = "foo",-      kind = "skill",-      version = mVersion,-      hash = h,-      installedAt = "2026-05-13T00:00:00Z",-      installedFiles = Nothing,-      installedHash = Nothing-    }--mkSkillItem :: Text -> Maybe Text -> KitItem-mkSkillItem n mVersion =-  KitSkillItem-    SkillEntry-      { name = n,-        description = "x",-        version = mVersion,-        path = "skills/foo",-        files = ["SKILL.md"]-      }--mkAgentItem :: Text -> Maybe Text -> KitItem-mkAgentItem n mVersion =-  KitAgentItem-    AgentEntry-      { name = n,-        description = "x",-        version = mVersion,-        path = "agents/foo.md",-        files = Nothing-      }--testConfig :: KitConfig-testConfig =-  KitConfig-    { toolName = "testkit",-      repoUrl = "file:///not-used",-      providers = [InteractiveClaude, InteractiveCodex]-    }--withPreparedKitHome :: (FilePath -> FilePath -> IO a) -> IO a-withPreparedKitHome action =-  withSystemTempDirectory "baikai-kit-home" $ \tmp -> do-    oldHome <- lookupEnv "HOME"-    let home = tmp </> "home"-        cache = home </> ".cache" </> "testkit" </> "kit"-    createDirectoryIfMissing True (cache </> ".git")-    createDirectoryIfMissing True (cache </> "skills" </> "demo")-    createDirectoryIfMissing True (cache </> "agents")-    BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "skill instructions\n"-    BS.writeFile (cache </> "agents" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"-    BS.writeFile (cache </> "kit.json") manifestJson-    setEnv "HOME" home-    action home cache `finally` restoreHome oldHome--restoreHome :: Maybe String -> IO ()-restoreHome Nothing = unsetEnv "HOME"-restoreHome (Just value) = setEnv "HOME" value---- | Plant a committed-symlink kit: a directory link out of the checkout---   and a manifest that lists a file below it.-plantSymlinkedSource :: FilePath -> FilePath -> IO ()-plantSymlinkedSource home cache = do-  let outsideDir = takeDirectory home </> "outside"-  createDirectoryIfMissing True outsideDir-  BS.writeFile (outsideDir </> "secret.txt") "top secret\n"-  createDirectoryLink outsideDir (cache </> "skills" </> "demo" </> "sub")-  BS.writeFile (cache </> "kit.json") manifestWithSymlinkedFileJson--manifestWithSymlinkedFileJson :: BS.ByteString-manifestWithSymlinkedFileJson =-  Text.Encoding.encodeUtf8 $-    Text.concat-      [ "{\"version\":2,",-        "\"skills\":[{",-        "\"name\":\"demo\",",-        "\"description\":\"Demo skill\",",-        "\"version\":\"0.1.0\",",-        "\"path\":\"skills/demo\",",-        "\"files\":[\"SKILL.md\",\"sub/secret.txt\"]",-        "}],",-        "\"agents\":[]} "-      ]---- | Rewrite the fixture kit so its agent lists two files below a---   directory of its own.-plantMultiFileAgent :: FilePath -> IO ()-plantMultiFileAgent cache = do-  createDirectoryIfMissing True (cache </> "agents" </> "reviewer")-  BS.writeFile (cache </> "agents" </> "reviewer" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"-  BS.writeFile (cache </> "agents" </> "reviewer" </> "guide.md") "How to review.\n"-  BS.writeFile (cache </> "kit.json") manifestWithMultiFileAgentJson--manifestWithMultiFileAgentJson :: BS.ByteString-manifestWithMultiFileAgentJson =-  Text.Encoding.encodeUtf8 $-    Text.concat-      [ "{\"version\":2,",-        "\"skills\":[],",-        "\"agents\":[{",-        "\"name\":\"reviewer\",",-        "\"description\":\"Review agent\",",-        "\"version\":\"0.1.0\",",-        "\"path\":\"agents/reviewer\",",-        "\"files\":[\"reviewer.md\",\"guide.md\"]",-        "}]} "-      ]--manifestWithUnsupportedVersionJson :: BS.ByteString-manifestWithUnsupportedVersionJson =-  Text.Encoding.encodeUtf8 $-    Text.concat-      [ "{\"version\":99,",-        "\"skills\":[{",-        "\"name\":\"demo\",",-        "\"description\":\"Demo skill\",",-        "\"version\":\"0.1.0\",",-        "\"path\":\"skills/demo\",",-        "\"files\":[\"SKILL.md\"]",-        "}],",-        "\"agents\":[]} "-      ]--legacySidecarJson :: BS.ByteString-legacySidecarJson =-  "{\"name\":\"demo\",\"kind\":\"skill\",\"version\":\"0.1.0\",\"hash\":\"sha256:x\",\"installedAt\":\"t\"}"--manifestJson :: BS.ByteString-manifestJson =-  Text.Encoding.encodeUtf8 $-    Text.concat-      [ "{\"version\":2,",-        "\"skills\":[{",-        "\"name\":\"demo\",",-        "\"description\":\"Demo skill\",",-        "\"version\":\"0.1.0\",",-        "\"path\":\"skills/demo\",",-        "\"files\":[\"SKILL.md\"]",-        "}],",-        "\"agents\":[{",-        "\"name\":\"reviewer\",",-        "\"description\":\"Review agent\",",-        "\"version\":\"0.1.0\",",-        "\"path\":\"agents/reviewer.md\"",-        "}]} "-      ]--manifestWithoutDemoJson :: BS.ByteString-manifestWithoutDemoJson =-  Text.Encoding.encodeUtf8 $-    Text.concat-      [ "{\"version\":2,",-        "\"skills\":[],",-        "\"agents\":[{",-        "\"name\":\"reviewer\",",-        "\"description\":\"Review agent\",",-        "\"version\":\"0.1.0\",",-        "\"path\":\"agents/reviewer.md\"",-        "}]} "-      ]--manifestWithDemoVersionJson :: BS.ByteString-manifestWithDemoVersionJson =-  Text.Encoding.encodeUtf8 $-    Text.concat-      [ "{\"version\":2,",-        "\"skills\":[{",-        "\"name\":\"demo\",",-        "\"description\":\"Demo skill\",",-        "\"version\":\"0.2.0\",",-        "\"path\":\"skills/demo\",",-        "\"files\":[\"SKILL.md\"]",-        "}],",-        "\"agents\":[{",-        "\"name\":\"reviewer\",",-        "\"description\":\"Review agent\",",-        "\"version\":\"0.1.0\",",-        "\"path\":\"agents/reviewer.md\"",-        "}]} "-      ]--maliciousManifestJson :: BS.ByteString-maliciousManifestJson =-  Text.Encoding.encodeUtf8 $-    Text.concat-      [ "{\"version\":2,",-        "\"skills\":[{",-        "\"name\":\"evil\",",-        "\"description\":\"Evil skill\",",-        "\"version\":\"0.1.0\",",-        "\"path\":\"skills/demo\",",-        "\"files\":[\"../../../../escape.txt\"]",-        "}],",-        "\"agents\":[]} "-      ]--isUnsafePath :: KitError -> Bool-isUnsafePath = \case KitUnsafePath _ _ -> True; _ -> False--isUnsafeName :: KitError -> Bool-isUnsafeName = \case KitUnsafeName _ _ -> True; _ -> False--isSourceSymlink :: KitError -> Bool-isSourceSymlink = \case KitSourceSymlink _ -> True; _ -> False--isWriteFailed :: KitError -> Bool-isWriteFailed = \case KitWriteFailed {} -> True; _ -> False--isPullFailed :: KitError -> Bool-isPullFailed = \case KitPullFailed _ -> True; _ -> False--isManifestMissing :: KitError -> Bool-isManifestMissing = \case KitManifestMissing _ -> True; _ -> False--isVersionUnsupported :: KitError -> Bool-isVersionUnsupported = \case KitManifestVersionUnsupported _ _ -> True; _ -> False--isManifestInvalid :: KitError -> Bool-isManifestInvalid = \case KitManifestInvalid _ _ -> True; _ -> False--assertKitError :: (Show a) => String -> (KitError -> Bool) -> Either KitError a -> IO ()-assertKitError label matches result =-  case result of-    Left err | matches err -> pure ()-    other -> assertFailure ("expected " <> label <> ", got " <> show other)--assertRight :: (Show e) => Either e a -> IO a-assertRight = \case-  Right value -> pure value-  Left err -> assertFailure ("expected Right, got Left " <> show err)--assertLeft :: (Show b) => Either a b -> IO ()-assertLeft result =-  case result of-    Left _ -> pure ()-    Right value -> assertFailure ("expected Left, got Right " <> show value)--assertFileExists :: FilePath -> IO ()-assertFileExists path = do-  exists <- doesFileExist path-  assertBool ("expected file to exist: " <> path) exists--assertFileMissing :: FilePath -> IO ()-assertFileMissing path = do-  exists <- doesFileExist path-  assertBool ("expected file to be missing: " <> path) (not exists)--assertDirectoryMissing :: FilePath -> IO ()-assertDirectoryMissing path = do-  exists <- doesDirectoryExist path-  assertBool ("expected directory to be missing: " <> path) (not exists)--assertDirectoryExists :: FilePath -> IO ()-assertDirectoryExists path = do-  exists <- doesDirectoryExist path-  assertBool ("expected directory to exist: " <> path) exists--findFilesWithSuffix :: FilePath -> String -> IO [FilePath]-findFilesWithSuffix root suffix = do-  exists <- doesPathExist root-  if not exists-    then pure []-    else do-      isDir <- doesDirectoryExist root-      if not isDir-        then pure [root | suffix `isSuffixOf` root]-        else do-          names <- listDirectory root-          fmap concat $ mapM (\name -> findFilesWithSuffix (root </> name) suffix) names--findOutcome :: InteractiveProvider -> [RemovalOutcome] -> RemovalOutcome-findOutcome expected outcomes =-  case find ((== expected) . view #provider) outcomes of-    Just outcome -> outcome-    Nothing -> error "expected provider outcome"+    InstallOptions (..),+    KitCommand (..),+    KitCondition (..),+    KitConfig (..),+    KitError (..),+    KitItem (..),+    KitItemKind (..),+    KitManifest (..),+    KitScope (..),+    KitVisibility (..),+    OutputFormat (..),+    OverwritePolicy (..),+    PlannedWrite (..),+    PullResult (..),+    RemovalOutcome (..),+    RepoRefresh (..),+    SidecarMeta (..),+    SkillEntry (..),+    UpstreamAvailability (..),+    WriteContent (..),+    addDisabledSkill,+    agentDirsForSession,+    checkVisibility,+    classify,+    codexSessionArgs,+    collectStatus,+    computeKitHash,+    conditionLabel,+    defaultInstallOptions,+    enableSkillsArgs,+    executePlanWith,+    findProjectRoot,+    installItem,+    installedCopies,+    kitCommandParser,+    kitConfig,+    kitJsonFormatVersion,+    kitStatus,+    listDocument,+    loadManifest,+    projectRootByMarkers,+    pullKitRepo,+    readSidecar,+    readSkillEntries,+    reinstallPresent,+    relativeLinkTarget,+    removeDisabledSkill,+    renderConditions,+    renderStatusTable,+    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.Bits ((.&.))+import Data.ByteString qualified as BS+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+  ( canonicalizePath,+    createDirectoryIfMissing,+    createDirectoryLink,+    createFileLink,+    doesDirectoryExist,+    doesFileExist,+    doesPathExist,+    getCurrentDirectory,+    getSymbolicLinkTarget,+    listDirectory,+    pathIsSymbolicLink,+    removeDirectoryRecursive,+    removeFile,+    renameFile,+    withCurrentDirectory,+  )+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, (</>))+import System.IO (hClose, hFlush, stdout)+import System.IO.Temp (withSystemTempDirectory, withSystemTempFile)+import System.Posix.Files qualified as Posix+import System.Process (readProcessWithExitCode)+import Test.Tasty (TestTree, defaultMain, localOption, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))+import Test.Tasty.Runners (NumThreads (NumThreads))+import Toml qualified++main :: IO ()+main =+  defaultMain $+    localOption (NumThreads 1) $+      testGroup+        "baikai-kit"+        [ manifestTests,+          hashTests,+          pathSafetyTests,+          symlinkSafetyTests,+          frontmatterTests,+          classifyTests,+          statusFilesystemTests,+          installRoundTripTests,+          typedErrorTests,+          installFidelityTests,+          projectRootTests,+          commandTests,+          jsonTests,+          visibilityTests,+          codexVisibilityTests,+          visibilityStatusTests,+          visibilityRecoveryTests+        ]++manifestTests :: TestTree+manifestTests =+  testGroup+    "Manifest backward compatibility"+    [ fixtureCase "mori-kit.json" 2 4 0,+      fixtureCase "rei-kit.json" 1 9 1,+      fixtureCase "seihou-kit.json" 1 2 0+    ]++fixtureCase :: FilePath -> Int -> Int -> Int -> TestTree+fixtureCase file expectedVersion expectedSkills expectedAgents =+  testCase (file <> " decodes") $ do+    manifest <- decodeFixture file+    (manifest ^. #version) @?= expectedVersion+    length (manifest ^. #skills) @?= expectedSkills+    length (manifest ^. #agents) @?= expectedAgents++hashTests :: TestTree+hashTests =+  testGroup+    "Hash"+    [ testCase "computeKitHash is deterministic regardless of input order" $+        withSystemTempDirectory "baikai-kit-hash" $ \dir -> do+          BS.writeFile (dir </> "a.md") "alpha"+          BS.writeFile (dir </> "b.md") "beta"+          BS.writeFile (dir </> "c.md") "gamma"+          h1 <- assertRight =<< computeKitHash dir "." ["a.md", "b.md", "c.md"]+          h2 <- assertRight =<< computeKitHash dir "." ["c.md", "a.md", "b.md"]+          h1 @?= h2+          assertBool "hash should carry sha256 prefix" ("sha256:" `Text.isPrefixOf` h1),+      testCase "computeKitHash changes when file content changes" $+        withSystemTempDirectory "baikai-kit-hash-mut" $ \dir -> do+          BS.writeFile (dir </> "a.md") "alpha"+          before <- assertRight =<< computeKitHash dir "." ["a.md"]+          BS.writeFile (dir </> "a.md") "alpha-modified"+          after <- assertRight =<< computeKitHash dir "." ["a.md"]+          assertBool "hashes must differ after content change" (before /= after)+    ]++classifyTests :: TestTree+classifyTests =+  testGroup+    "Status.classify"+    [ testCase "no sidecar => unknown" $+        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],+      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 => outdated+changed-upstream" $+        classify+          (Just (mkSidecar (Just "1.0") "h1"))+          (Just (mkSkillItem "foo" (Just "2.0")))+          (Just "h2")+          @?= [KitOutdated, KitChangedUpstream],+      testCase "hash mismatch => changed-upstream" $+        classify+          (Just (mkSidecar (Just "1.0") "h1"))+          (Just (mkSkillItem "foo" (Just "1.0")))+          (Just "h2")+          @?= [KitChangedUpstream],+      testCase "version and hash match => up-to-date" $+        classify+          (Just (mkSidecar (Just "1.0") "h"))+          (Just (mkSkillItem "foo" (Just "1.0")))+          (Just "h")+          @?= [],+      testCase "no upstream hash on matching version => up-to-date" $+        classify+          (Just (mkSidecar (Just "1.0") "h"))+          (Just (mkAgentItem "foo" (Just "1.0")))+          Nothing+          @?= [],+      testCase "renderConditions joins labels in order" $ do+        renderConditions [] @?= "up-to-date"+        renderConditions [KitOutdated, KitChangedUpstream, KitLocallyModified] @?= "outdated+changed-upstream+modified"+    ]++pathSafetyTests :: TestTree+pathSafetyTests =+  testGroup+    "Path safety"+    [ testCase "safeRelativePath accepts and normalises harmless paths" $ do+        safeRelativePath "SKILL.md" @?= Right "SKILL.md"+        safeRelativePath "skills/review" @?= Right "skills/review"+        safeRelativePath "a/./b" @?= Right ("a" </> "b"),+      testCase "safeRelativePath rejects zip-slip, absolute paths, backslashes, and NUL" $ do+        mapM_+          (assertLeft . safeRelativePath)+          ["", "/etc/passwd", "../x", "a/../../x", "..", "a/..", "a\\..\\b", "a\0b"],+      testCase "safeItemName rejects multi-component and hidden names" $ do+        safeItemName "reviewer" @?= Right "reviewer"+        mapM_ (assertLeft . safeItemName) ["a/b", ".", ".hidden"],+      testCase "install refuses a manifest file path that escapes the install root" $+        withPreparedKitHome $ \home cache -> do+          BS.writeFile (cache </> "kit.json") maliciousManifestJson+          result <- installItem testConfig "evil" UserScope defaultInstallOptions+          assertKitError "KitUnsafePath" isUnsafePath result+          assertFileMissing (takeDirectory home </> "escape.txt")+          exitResult <- try @ExitCode (runKit testConfig (KitInstall (Just "evil") UserScope defaultInstallOptions))+          exitResult @?= Left (ExitFailure 1),+      testCase "uninstall refuses a traversal name" $+        withPreparedKitHome $ \home _cache -> do+          let victim = home </> "victim"+          createDirectoryIfMissing True victim+          result <- uninstallItem testConfig "../victim" UserScope+          assertKitError "KitUnsafeName" isUnsafeName result+          assertDirectoryExists victim+          exitResult <- try @ExitCode (runKit testConfig (KitUninstall "../victim" UserScope))+          exitResult @?= Left (ExitFailure 1)+          assertDirectoryExists victim+    ]++symlinkSafetyTests :: TestTree+symlinkSafetyTests =+  testGroup+    "Symlink safety"+    [ testCase "safeSourcePath refuses a symlinked component" $+        withPreparedKitHome $ \home cache -> do+          plantSymlinkedSource home cache+          refused <- safeSourcePath cache ("skills" </> "demo" </> "sub" </> "secret.txt")+          refused @?= Left (KitSourceSymlink (cache </> "skills" </> "demo" </> "sub"))+          absolute <- safeSourcePath cache "/etc/passwd"+          case absolute of+            Left (KitUnsafePath _ _) -> pure ()+            other -> assertFailure ("expected KitUnsafePath, got " <> show other)+          plain <- safeSourcePath cache ("skills" </> "demo" </> "SKILL.md")+          plain @?= Right (cache </> "skills" </> "demo" </> "SKILL.md"),+      testCase "computeKitHash refuses a symlinked source" $+        withPreparedKitHome $ \home cache -> do+          plantSymlinkedSource home cache+          hashed <- computeKitHash cache ("skills" </> "demo") ["SKILL.md", "sub" </> "secret.txt"]+          hashed @?= Left (KitSourceSymlink (cache </> "skills" </> "demo" </> "sub")),+      testCase "install refuses a symlinked source and writes nothing" $+        withPreparedKitHome $ \home cache -> do+          plantSymlinkedSource home cache+          let claudeSkill = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"+          result <- installItem testConfig "demo" UserScope defaultInstallOptions+          assertKitError "KitSourceSymlink" isSourceSymlink result+          assertFileMissing (claudeSkill </> "sub" </> "secret.txt")+          assertFileMissing (claudeSkill </> "SKILL.md"),+      testCase "status reports refused when upstream lists a symlinked source" $+        withPreparedKitHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          plantSymlinkedSource home cache+          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 @?= [KitUpstreamRefused]) demoRows,+      testCase "conditionLabel names the refused condition" $+        conditionLabel KitUpstreamRefused @?= "refused"+    ]++frontmatterTests :: TestTree+frontmatterTests =+  testGroup+    "Frontmatter"+    [ testCase "stripYamlFrontmatter handles LF, CRLF, and final delimiter without newline" $ do+        stripYamlFrontmatter "---\nname: x\n---\nBody.\n" @?= "Body.\n"+        stripYamlFrontmatter "---\r\nname: x\r\n---\r\nBody.\r\n" @?= "Body.\n"+        stripYamlFrontmatter "---\nname: x\n---" @?= "",+      testCase "stripYamlFrontmatter leaves non-frontmatter and unterminated blocks unchanged" $ do+        stripYamlFrontmatter "Body.\n" @?= "Body.\n"+        stripYamlFrontmatter "---\nname: x\nBody.\n" @?= "---\nname: x\nBody.\n",+      testCase "stripYamlFrontmatter normalises line endings on every branch" $ do+        stripYamlFrontmatter "Body.\r\n" @?= "Body.\n"+        stripYamlFrontmatter "---\r\nname: x\r\nBody.\r\n" @?= "---\nname: x\nBody.\n"+    ]++statusFilesystemTests :: TestTree+statusFilesystemTests =+  testGroup+    "Status filesystem"+    [ testCase "sidecarPath drops any agent target extension" $+        withPreparedKitHome $ \home _cache -> do+          let claudeBase = home </> ".config" </> "testkit" </> "agents"+              codexBase = home+              sidecar = sidecarFileName testConfig+          sidecarPath InteractiveClaude AgentKind "reviewer" claudeBase sidecar+            @?= claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"+          sidecarPath InteractiveCodex AgentKind "reviewer" codexBase sidecar+            @?= codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json",+      testCase "delisted installed item keeps sidecar version in status" $+        withPreparedKitHome $ \_home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          BS.writeFile (cache </> "kit.json") manifestWithoutDemoJson+          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 @?= [KitDelisted]) demoRows+          mapM_ (\row -> row ^. #installedVersion @?= Just "0.1.0") demoRows,+      testCase "version and cached hash drift reports outdated+changed-upstream" $+        withPreparedKitHome $ \_home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "changed instructions\n"+          BS.writeFile (cache </> "kit.json") manifestWithDemoVersionJson+          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 @?= [KitOutdated, KitChangedUpstream]) demoRows,+      testCase "an installed item reports no conditions before an edit" $+        withPreparedKitHome $ \_home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          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 defaultInstallOptions+          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 defaultInstallOptions+          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 defaultInstallOptions+          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 defaultInstallOptions+          _ <- assertRight =<< installItem config "reviewer" UserScope (InstallOptions Nothing True)+          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 =+  testGroup+    "Install"+    [ testCase "skill and agent round-trip through Claude and Codex layouts with sidecars" $+        withPreparedKitHome $ \home _cache -> do+          let config = testConfig+              claudeBase = home </> ".config" </> "testkit" </> "agents"+              codexBase = home+              claudeSkill = claudeBase </> ".claude" </> "skills" </> "demo"+              codexSkill = codexBase </> ".agents" </> "skills" </> "demo"+              claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"+              codexAgent = codexBase </> ".codex" </> "agents" </> "reviewer.toml"+              claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"+              codexAgentSidecar = codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json"+          _ <- assertRight =<< installItem config "demo" UserScope defaultInstallOptions+          assertFileExists (claudeSkill </> "SKILL.md")+          assertFileExists (codexSkill </> "SKILL.md")+          assertFileExists (claudeSkill </> ".testkit-kit.json")+          meta <- readSidecar (claudeSkill </> ".testkit-kit.json")+          case meta of+            Just sidecar -> do+              (sidecar ^. #name) @?= ("demo" :: Text)+              (sidecar ^. #kind) @?= ("skill" :: Text)+            Nothing -> assertFailure "expected a skill sidecar"+          _ <- assertRight =<< uninstallItem config "demo" UserScope+          assertDirectoryMissing claudeSkill+          assertDirectoryMissing codexSkill+          _ <- assertRight =<< installItem config "reviewer" UserScope (InstallOptions Nothing True)+          assertFileExists claudeAgent+          assertFileExists codexAgent+          assertFileExists claudeAgentSidecar+          assertFileExists codexAgentSidecar+          toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent+          assertBool "Codex agent TOML should contain developer instructions" ("developer_instructions" `Text.isInfixOf` toml)+          _ <- assertRight =<< uninstallItem config "reviewer" UserScope+          assertFileMissing claudeAgent+          assertFileMissing codexAgent+          assertFileMissing claudeAgentSidecar+          assertFileMissing codexAgentSidecar,+      testCase "Codex TOML strips CRLF YAML frontmatter" $+        withPreparedKitHome $ \home cache -> do+          let codexAgent = home </> ".codex" </> "agents" </> "reviewer.toml"+          BS.writeFile (cache </> "agents" </> "reviewer.md") "---\r\nname: reviewer\r\n---\r\nReview carefully.\r\n"+          _ <- assertRight =<< installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+          toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent+          assertBool "frontmatter name should be stripped" (not ("name: reviewer" `Text.isInfixOf` toml))+          assertBool "CR characters should be stripped" (not ("\r" `Text.isInfixOf` toml)),+      testCase "failed provider write rolls back all staged writes" $+        withPreparedKitHome $ \home _cache -> do+          let claudeBase = home </> ".config" </> "testkit" </> "agents"+              claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"+              claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"+          BS.writeFile (home </> ".codex") ""+          result <- installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+          assertKitError "KitWriteFailed" isWriteFailed result+          assertFileMissing claudeAgent+          assertFileMissing claudeAgentSidecar+          tmpFiles <- findFilesWithSuffix home ".baikai-kit-tmp"+          tmpFiles @?= [],+      testCase "renderUninstallReport names actual assets and stale metadata" $ do+        renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False [] []]+          @?= "Uninstalled skill 'demo' from user scope (claude)."+        renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False [] [], RemovalOutcome InteractiveCodex True False False [] []]+          @?= "Uninstalled skill 'demo' from user scope (claude,codex)."+        renderUninstallReport "reviewer" UserScope [RemovalOutcome InteractiveClaude False False True [] []]+          @?= "Removed stale kit metadata for 'reviewer' from user scope."+        renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude False False False [] []]+          @?= "'demo' is not installed in user scope.",+      testCase "uninstallItem reports per-provider removals" $+        withPreparedKitHome $ \home _cache -> do+          let codexSkill = home </> ".agents" </> "skills" </> "demo"+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          removeDirectoryRecursive codexSkill+          outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope+          let claudeOutcome = findOutcome InteractiveClaude outcomes+              codexOutcome = findOutcome InteractiveCodex outcomes+          view #skillRemoved claudeOutcome @?= True+          view #skillRemoved codexOutcome @?= False+          view #agentRemoved codexOutcome @?= False+          view #sidecarRemoved codexOutcome @?= False,+      testCase "pull failure is returned as a typed error" $+        withPreparedKitHome $ \_home cache -> do+          result <- pullKitRepo testConfig cache+          case result of+            PullFailed _ -> pure ()+            PullSucceeded -> assertFailure "expected fake .git cache pull to fail"+          updateResult <- updateKit testConfig Nothing KeepLocalEdits+          assertKitError "KitPullFailed" isPullFailed updateResult+    ]++typedErrorTests :: TestTree+typedErrorTests =+  testGroup+    "Typed errors"+    [ testCase "loadManifest returns typed errors" $+        withSystemTempDirectory "baikai-kit-manifest" $ \dir -> do+          missing <- loadManifest dir+          assertKitError "KitManifestMissing" isManifestMissing missing+          BS.writeFile (dir </> "kit.json") "{"+          invalid <- loadManifest dir+          assertKitError "KitManifestInvalid" isManifestInvalid invalid,+      testCase "installItem returns KitItemNotFound" $+        withPreparedKitHome $ \_home _cache -> do+          result <- installItem testConfig "nope" UserScope defaultInstallOptions+          assertKitError "KitItemNotFound" (== KitItemNotFound "nope") result,+      testCase "kit status offline on a fresh HOME exits 0" $+        withSystemTempDirectory "baikai-kit-offline" $ \tmp -> do+          oldHome <- lookupEnv "HOME"+          oldCodexHome <- lookupEnv "CODEX_HOME"+          let home = tmp </> "home"+              config = testConfig & #repoUrl .~ "file:///nonexistent-kit"+          createDirectoryIfMissing True home+          setEnv "HOME" home+          setEnv "CODEX_HOME" (home </> ".codex")+          flip finally (restoreHome oldHome >> restoreCodexHome oldCodexHome) $ do+            exitResult <- try @ExitCode (runKit config (KitStatus HumanOutput))+            exitResult @?= Right ()+            report <- kitStatus config+            (report ^. #rows) @?= []+            case report ^. #upstream of+              UpstreamUnavailable (KitCloneFailed _ _) -> pure ()+              other -> assertFailure ("expected an unavailable upstream, got " <> show other)+    ]++installFidelityTests :: TestTree+installFidelityTests =+  testGroup+    "Install fidelity"+    [ testCase "multi-file agent installs every listed file" $+        withPreparedKitHome $ \home cache -> do+          plantMultiFileAgent cache+          let claudeBase = home </> ".config" </> "testkit" </> "agents"+              claudeAgents = claudeBase </> ".claude" </> "agents"+              codexAgents = home </> ".codex" </> "agents"+          _ <- assertRight =<< installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+          assertFileExists (claudeAgents </> "reviewer.md")+          assertFileExists (claudeAgents </> "reviewer" </> "guide.md")+          assertFileExists (codexAgents </> "reviewer.toml")+          assertFileExists (codexAgents </> "reviewer" </> "guide.md")+          meta <- readSidecar (claudeAgents </> "reviewer.testkit-kit.json")+          case meta of+            Just sidecar -> (sidecar ^. #installedFiles) @?= Just ["reviewer.md", "reviewer" <> "/" <> "guide.md"]+            Nothing -> assertFailure "expected an agent sidecar"+          _ <- assertRight =<< uninstallItem testConfig "reviewer" UserScope+          assertDirectoryMissing (claudeAgents </> "reviewer")+          assertDirectoryMissing (codexAgents </> "reviewer"),+      testCase "phase-two failure restores the previous files" $+        withSystemTempDirectory "baikai-kit-journal" $ \dir -> do+          BS.writeFile (dir </> "a.txt") "old"+          let writes =+                [ PlannedWrite {destination = dir </> "a.txt", content = WriteBytes "new"},+                  PlannedWrite {destination = dir </> "b.txt", content = WriteBytes "new"}+                ]+              failingRename temp dest+                | "b.txt" `isSuffixOf` dest = do+                    takeDirectory temp @?= dir+                    assertBool "temporary should keep the tmp suffix" (".baikai-kit-tmp" `isSuffixOf` temp)+                    assertBool "temporary name should be unique" (temp /= dest <> ".baikai-kit-tmp")+                    ioError (userError "boom")+                | otherwise = renameFile temp dest+          result <- executePlanWith failingRename writes+          case result of+            Left (KitWriteFailed _ restored broken) -> do+              restored @?= [dir </> "a.txt"]+              broken @?= []+            other -> assertFailure ("expected KitWriteFailed, got " <> show other)+          kept <- BS.readFile (dir </> "a.txt")+          kept @?= "old"+          assertFileMissing (dir </> "b.txt")+          tmpFiles <- findFilesWithSuffix dir ".baikai-kit-tmp"+          tmpFiles @?= []+          bakFiles <- findFilesWithSuffix dir ".baikai-kit-bak"+          bakFiles @?= [],+      testCase "destination directory is refused before any write" $+        withPreparedKitHome $ \home _cache -> do+          let claudeAgents = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "agents"+          createDirectoryIfMissing True (claudeAgents </> "reviewer.md")+          result <- installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+          assertKitError "KitWriteFailed" isWriteFailed result+          assertFileMissing (home </> ".codex" </> "agents" </> "reviewer.toml"),+      testCase "unsupported manifest version is refused" $+        withPreparedKitHome $ \_home cache -> do+          BS.writeFile (cache </> "kit.json") manifestWithUnsupportedVersionJson+          loaded <- loadManifest cache+          case loaded of+            Left (KitManifestVersionUnsupported _ 99) -> pure ()+            other -> assertFailure ("expected KitManifestVersionUnsupported 99, got " <> show other)+          installed <- installItem testConfig "demo" UserScope defaultInstallOptions+          assertKitError "KitManifestVersionUnsupported" isVersionUnsupported installed,+      testCase "a sidecar written before the installed-file fields still decodes" $+        case Aeson.eitherDecodeStrict' legacySidecarJson :: Either String SidecarMeta of+          Right meta -> do+            (meta ^. #name) @?= ("demo" :: Text)+            (meta ^. #installedFiles) @?= Nothing+            (meta ^. #installedHash) @?= Nothing+          Left err -> assertFailure ("expected a legacy sidecar to decode: " <> err),+      testCase "update skips locally modified items unless forced" $+        withPreparedKitHome $ \home cache -> do+          let claudeSkill = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"+              upstream = cache </> "skills" </> "demo" </> "SKILL.md"+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          BS.writeFile (claudeSkill </> "SKILL.md") "my edits"+          BS.writeFile upstream "new upstream"+          manifest <- assertRight =<< loadManifest cache+          kept <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits+          (kept ^. #skipped) @?= [("demo", UserScope)]+          (kept ^. #updated) @?= []+          mine <- BS.readFile (claudeSkill </> "SKILL.md")+          mine @?= "my edits"+          forced <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") OverwriteLocalEdits+          (forced ^. #updated) @?= [("demo", UserScope)]+          (forced ^. #skipped) @?= []+          fresh <- BS.readFile (claudeSkill </> "SKILL.md")+          fresh @?= "new upstream"+          -- A sidecar from before this release records no installed hash,+          -- so the item is reinstalled without the check.+          BS.writeFile (claudeSkill </> ".testkit-kit.json") legacySidecarJson+          BS.writeFile (claudeSkill </> "SKILL.md") "my edits again"+          legacy <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits+          (legacy ^. #updated) @?= [("demo", UserScope)]+          (legacy ^. #skipped) @?= [],+      testCase "a write failure during update is returned, not thrown" $+        withPreparedKitHome $ \home _cache -> do+          let claudeAgents = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "agents"+              cache = home </> ".cache" </> "testkit" </> "kit"+          _ <- assertRight =<< installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+          removeFile (claudeAgents </> "reviewer.md")+          createDirectoryIfMissing True (claudeAgents </> "reviewer.md")+          manifest <- assertRight =<< loadManifest cache+          result <- reinstallPresent testConfig cache manifest (Just "reviewer") OverwriteLocalEdits+          assertKitError "KitWriteFailed" isWriteFailed result+    ]++decodeFixture :: FilePath -> IO KitManifest+decodeFixture file = do+  bytes <- BS.readFile ("test/fixtures" </> file)+  case Aeson.eitherDecodeStrict' bytes of+    Right manifest -> pure manifest+    Left err -> assertFailure ("failed to decode " <> file <> ": " <> err)++mkSidecar :: Maybe Text -> Text -> SidecarMeta+mkSidecar mVersion h =+  SidecarMeta+    { name = "foo",+      kind = "skill",+      version = mVersion,+      hash = h,+      installedAt = "2026-05-13T00:00:00Z",+      installedFiles = Nothing,+      installedHash = Nothing,+      visibility = Nothing,+      visibilitySource = Nothing,+      sharedLinks = Nothing,+      codexDisabledSkills = Nothing+    }++mkSkillItem :: Text -> Maybe Text -> KitItem+mkSkillItem n mVersion =+  KitSkillItem+    SkillEntry+      { name = n,+        description = "x",+        version = mVersion,+        path = "skills/foo",+        files = ["SKILL.md"],+        visibility = Nothing+      }++mkAgentItem :: Text -> Maybe Text -> KitItem+mkAgentItem n mVersion =+  KitAgentItem+    AgentEntry+      { name = n,+        description = "x",+        version = mVersion,+        path = "agents/foo.md",+        files = Nothing,+        visibility = 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 defaultInstallOptions+          _ <- assertRight =<< installItem config "reviewer" UserScope (InstallOptions Nothing True)+          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 defaultInstallOptions+          (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"+    oldCodexHome <- lookupEnv "CODEX_HOME"+    let home = tmp </> "home"+    createDirectoryIfMissing True home+    setEnv "HOME" home+    setEnv "CODEX_HOME" (home </> ".codex")+    action home `finally` (restoreHome oldHome >> restoreCodexHome oldCodexHome)++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 (InstallOptions Nothing True))+    forM_ ["beta", "delta", "planner"] $ \n ->+      void (assertRight =<< installItem config n ProjectScope (InstallOptions Nothing True))+    -- alpha: the Codex copy loses its sidecar => unknown.+    removeFile (home </> ".agents" </> "skills" </> "alpha" </> ".testkit-kit.json")+    -- gamma: its Claude shared link is lost; the Codex copy predates visibility.+    removeFile (home </> ".claude/skills/gamma")+    let gammaSidecar = home </> ".agents/skills/gamma/.testkit-kit.json"+    gammaMeta <- requireSidecar gammaSidecar+    LBS.writeFile gammaSidecar (Aeson.encode (gammaMeta & #visibility .~ Nothing & #visibilitySource .~ Nothing))+    -- 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+        <> (if n `elem` ["alpha", "gamma"] then ",\"visibility\":\"shared\"" else "")+        <> "}"+    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 defaultInstallOptions)+        parse ["install", "demo", "--project"] @?= Just (KitInstall (Just "demo") ProjectScope defaultInstallOptions)+        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 defaultInstallOptions)+        result @?= Left KitItemNameRequired+        exitResult <- try @ExitCode (runKit testConfig (KitInstall Nothing UserScope defaultInstallOptions))+        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 defaultInstallOptions)+          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 defaultInstallOptions)+          result @?= Right ()+          assertDirectoryMissing (userClaudeDemo home)+          exitResult <- try @ExitCode (runKit config (KitInstall Nothing UserScope defaultInstallOptions))+          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 defaultInstallOptions)+          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 defaultInstallOptions)+            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 defaultInstallOptions)+            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 defaultInstallOptions+              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 "testkit" "file:///not-used" [InteractiveClaude, InteractiveCodex]++withPreparedKitHome :: (FilePath -> FilePath -> IO a) -> IO a+withPreparedKitHome action =+  withSystemTempDirectory "baikai-kit-home" $ \tmp -> do+    oldHome <- lookupEnv "HOME"+    oldCodexHome <- lookupEnv "CODEX_HOME"+    let home = tmp </> "home"+        cache = home </> ".cache" </> "testkit" </> "kit"+    createDirectoryIfMissing True (cache </> ".git")+    createDirectoryIfMissing True (cache </> "skills" </> "demo")+    createDirectoryIfMissing True (cache </> "agents")+    BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "skill instructions\n"+    BS.writeFile (cache </> "agents" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"+    BS.writeFile (cache </> "kit.json") manifestJson+    setEnv "HOME" home+    setEnv "CODEX_HOME" (home </> ".codex")+    action home cache `finally` (restoreHome oldHome >> restoreCodexHome oldCodexHome)++restoreCodexHome :: Maybe String -> IO ()+restoreCodexHome Nothing = unsetEnv "CODEX_HOME"+restoreCodexHome (Just value) = setEnv "CODEX_HOME" value++restoreHome :: Maybe String -> IO ()+restoreHome Nothing = unsetEnv "HOME"+restoreHome (Just value) = setEnv "HOME" value++-- | Plant a committed-symlink kit: a directory link out of the checkout+--   and a manifest that lists a file below it.+plantSymlinkedSource :: FilePath -> FilePath -> IO ()+plantSymlinkedSource home cache = do+  let outsideDir = takeDirectory home </> "outside"+  createDirectoryIfMissing True outsideDir+  BS.writeFile (outsideDir </> "secret.txt") "top secret\n"+  createDirectoryLink outsideDir (cache </> "skills" </> "demo" </> "sub")+  BS.writeFile (cache </> "kit.json") manifestWithSymlinkedFileJson++manifestWithSymlinkedFileJson :: BS.ByteString+manifestWithSymlinkedFileJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[{",+        "\"name\":\"demo\",",+        "\"description\":\"Demo skill\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"skills/demo\",",+        "\"files\":[\"SKILL.md\",\"sub/secret.txt\"]",+        "}],",+        "\"agents\":[]} "+      ]++-- | Rewrite the fixture kit so its agent lists two files below a+--   directory of its own.+plantMultiFileAgent :: FilePath -> IO ()+plantMultiFileAgent cache = do+  createDirectoryIfMissing True (cache </> "agents" </> "reviewer")+  BS.writeFile (cache </> "agents" </> "reviewer" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"+  BS.writeFile (cache </> "agents" </> "reviewer" </> "guide.md") "How to review.\n"+  BS.writeFile (cache </> "kit.json") manifestWithMultiFileAgentJson++manifestWithMultiFileAgentJson :: BS.ByteString+manifestWithMultiFileAgentJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[],",+        "\"agents\":[{",+        "\"name\":\"reviewer\",",+        "\"description\":\"Review agent\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"agents/reviewer\",",+        "\"files\":[\"reviewer.md\",\"guide.md\"]",+        "}]} "+      ]++manifestWithUnsupportedVersionJson :: BS.ByteString+manifestWithUnsupportedVersionJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":99,",+        "\"skills\":[{",+        "\"name\":\"demo\",",+        "\"description\":\"Demo skill\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"skills/demo\",",+        "\"files\":[\"SKILL.md\"]",+        "}],",+        "\"agents\":[]} "+      ]++legacySidecarJson :: BS.ByteString+legacySidecarJson =+  "{\"name\":\"demo\",\"kind\":\"skill\",\"version\":\"0.1.0\",\"hash\":\"sha256:x\",\"installedAt\":\"t\"}"++manifestJson :: BS.ByteString+manifestJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[{",+        "\"name\":\"demo\",",+        "\"description\":\"Demo skill\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"skills/demo\",",+        "\"files\":[\"SKILL.md\"]",+        "}],",+        "\"agents\":[{",+        "\"name\":\"reviewer\",",+        "\"description\":\"Review agent\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"agents/reviewer.md\"",+        "}]} "+      ]++manifestWithoutDemoJson :: BS.ByteString+manifestWithoutDemoJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[],",+        "\"agents\":[{",+        "\"name\":\"reviewer\",",+        "\"description\":\"Review agent\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"agents/reviewer.md\"",+        "}]} "+      ]++manifestWithDemoVersionJson :: BS.ByteString+manifestWithDemoVersionJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[{",+        "\"name\":\"demo\",",+        "\"description\":\"Demo skill\",",+        "\"version\":\"0.2.0\",",+        "\"path\":\"skills/demo\",",+        "\"files\":[\"SKILL.md\"]",+        "}],",+        "\"agents\":[{",+        "\"name\":\"reviewer\",",+        "\"description\":\"Review agent\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"agents/reviewer.md\"",+        "}]} "+      ]++maliciousManifestJson :: BS.ByteString+maliciousManifestJson =+  Text.Encoding.encodeUtf8 $+    Text.concat+      [ "{\"version\":2,",+        "\"skills\":[{",+        "\"name\":\"evil\",",+        "\"description\":\"Evil skill\",",+        "\"version\":\"0.1.0\",",+        "\"path\":\"skills/demo\",",+        "\"files\":[\"../../../../escape.txt\"]",+        "}],",+        "\"agents\":[]} "+      ]++isUnsafePath :: KitError -> Bool+isUnsafePath = \case KitUnsafePath _ _ -> True; _ -> False++isUnsafeName :: KitError -> Bool+isUnsafeName = \case KitUnsafeName _ _ -> True; _ -> False++isSourceSymlink :: KitError -> Bool+isSourceSymlink = \case KitSourceSymlink _ -> True; _ -> False++isWriteFailed :: KitError -> Bool+isWriteFailed = \case KitWriteFailed {} -> True; _ -> False++isPullFailed :: KitError -> Bool+isPullFailed = \case KitPullFailed _ -> True; _ -> False++isManifestMissing :: KitError -> Bool+isManifestMissing = \case KitManifestMissing _ -> True; _ -> False++isVersionUnsupported :: KitError -> Bool+isVersionUnsupported = \case KitManifestVersionUnsupported _ _ -> True; _ -> False++isManifestInvalid :: KitError -> Bool+isManifestInvalid = \case KitManifestInvalid _ _ -> True; _ -> False++assertKitError :: (Show a) => String -> (KitError -> Bool) -> Either KitError a -> IO ()+assertKitError label matches result =+  case result of+    Left err | matches err -> pure ()+    other -> assertFailure ("expected " <> label <> ", got " <> show other)++assertRight :: (Show e) => Either e a -> IO a+assertRight = \case+  Right value -> pure value+  Left err -> assertFailure ("expected Right, got Left " <> show err)++assertLeft :: (Show b) => Either a b -> IO ()+assertLeft result =+  case result of+    Left _ -> pure ()+    Right value -> assertFailure ("expected Left, got Right " <> show value)++assertFileExists :: FilePath -> IO ()+assertFileExists path = do+  exists <- doesFileExist path+  assertBool ("expected file to exist: " <> path) exists++assertFileMissing :: FilePath -> IO ()+assertFileMissing path = do+  exists <- doesFileExist path+  assertBool ("expected file to be missing: " <> path) (not exists)++assertDirectoryMissing :: FilePath -> IO ()+assertDirectoryMissing path = do+  exists <- doesDirectoryExist path+  assertBool ("expected directory to be missing: " <> path) (not exists)++assertDirectoryExists :: FilePath -> IO ()+assertDirectoryExists path = do+  exists <- doesDirectoryExist path+  assertBool ("expected directory to exist: " <> path) exists++findFilesWithSuffix :: FilePath -> String -> IO [FilePath]+findFilesWithSuffix root suffix = do+  exists <- doesPathExist root+  if not exists+    then pure []+    else do+      isDir <- doesDirectoryExist root+      if not isDir+        then pure [root | suffix `isSuffixOf` root]+        else do+          names <- listDirectory root+          fmap concat $ mapM (\name -> findFilesWithSuffix (root </> name) suffix) names++findOutcome :: InteractiveProvider -> [RemovalOutcome] -> RemovalOutcome+findOutcome expected outcomes =+  case find ((== expected) . view #provider) outcomes of+    Just outcome -> outcome+    Nothing -> error "expected provider outcome"++visibilityTests :: TestTree+visibilityTests =+  testGroup+    "visibility"+    [ testCase "a manifest item without visibility installs tool-only" $+        withPreparedKitHome $ \home _ -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          assertFileExists (ownedDemo home </> "SKILL.md")+          assertDirectoryMissing (sharedDemo home)+          meta <- demoSidecar home+          meta ^. #visibility @?= Just "tool-only"+          meta ^. #visibilitySource @?= Just "manifest",+      testCase "a shared item links into ~/.claude/skills" $+        withPreparedKitHome $ \home cache -> do+          declareShared cache+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          getSymbolicLinkTarget (sharedDemo home) >>= (@?= ownedDemo home)+          BS.readFile (sharedDemo home </> "SKILL.md") >>= (@?= "skill instructions\n")+          meta <- demoSidecar home+          meta ^. #sharedLinks @?= Just [Text.pack (sharedDemo home)],+      testCase "a project-scope shared item uses a relative link" $+        withPreparedKitHome $ \home _ -> do+          let root = takeDirectory home </> "project"+              config = testConfig & #projectRoot .~ pure root+          _ <- assertRight =<< installItem config "demo" ProjectScope sharedOptions+          getSymbolicLinkTarget (root </> ".claude/skills/demo") >>= (@?= "../../.testkit/agents/.claude/skills/demo"),+      testCase "relativeLinkTarget handles siblings and descendants" $ do+        relativeLinkTarget "/a/.claude/skills" "/a/.tool/agents/.claude/skills/demo" @?= "../../.tool/agents/.claude/skills/demo"+        relativeLinkTarget "/a" "/a/b/c" @?= "b/c",+      testCase "kit install --shared overrides the manifest and update keeps it" $+        withPreparedKitHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+          updateDemo cache+          assertBool "shared link survives" =<< pathIsSymbolicLink (sharedDemo home)+          meta <- demoSidecar home+          meta ^. #visibilitySource @?= Just "install-flag",+      testCase "update follows a changed manifest default" $+        withPreparedKitHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          declareShared cache+          updateDemo cache+          assertBool "new manifest creates link" =<< pathIsSymbolicLink (sharedDemo home),+      testCase "update changes linked content without touching the link" $+        withPreparedKitHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+          before <- getSymbolicLinkTarget (sharedDemo home)+          inodeBefore <- Posix.getSymbolicLinkStatus (sharedDemo home)+          BS.writeFile (cache </> "skills/demo/SKILL.md") "new content\n"+          updateDemo cache+          getSymbolicLinkTarget (sharedDemo home) >>= (@?= before)+          inodeAfter <- Posix.getSymbolicLinkStatus (sharedDemo home)+          Posix.fileID inodeAfter @?= Posix.fileID inodeBefore+          Posix.modificationTimeHiRes inodeAfter @?= Posix.modificationTimeHiRes inodeBefore+          BS.readFile (sharedDemo home </> "SKILL.md") >>= (@?= "new content\n"),+      testCase "update recreates a deleted link including when local edits skip content" $+        withPreparedKitHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+          removeFile (sharedDemo home)+          updateDemo cache+          assertBool "link repaired" =<< pathIsSymbolicLink (sharedDemo home)+          BS.writeFile (ownedDemo home </> "SKILL.md") "local edits\n"+          removeFile (sharedDemo home)+          manifest <- assertRight =<< loadManifest cache+          report <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits+          report ^. #skipped @?= [("demo", UserScope)]+          BS.readFile (sharedDemo home </> "SKILL.md") >>= (@?= "local edits\n"),+      testCase "uninstall removes the link and nothing else" $+        withPreparedKitHome $ \home _ -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+          createDirectoryIfMissing True (home </> ".claude/skills/other")+          outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope+          concatMap (view #linksRemoved) outcomes @?= [sharedDemo home]+          assertDirectoryExists (home </> ".claude/skills/other")+          assertDirectoryMissing (sharedDemo home),+      testCase "a shared install refuses a foreign skill directory before any write" $+        withPreparedKitHome $ \home _ -> do+          createDirectoryIfMissing True (sharedDemo home)+          BS.writeFile (sharedDemo home </> "SKILL.md") "foreign\n"+          result <- installItem testConfig "demo" UserScope sharedOptions+          assertKitError "shared name taken" isNameTaken result+          BS.readFile (sharedDemo home </> "SKILL.md") >>= (@?= "foreign\n")+          assertDirectoryMissing (ownedDemo home)+          assertDirectoryMissing (home </> ".agents/skills/demo"),+      testCase "a shared install names the owning tool even for a dangling link" $+        withPreparedKitHome $ \home _ -> do+          createDirectoryIfMissing True (home </> ".claude/skills")+          createDirectoryLink (home </> ".config/rei/agents/.claude/skills/demo") (sharedDemo home)+          result <- installItem testConfig "demo" UserScope sharedOptions+          case result of+            Left (KitSharedNameTaken _ owner) -> owner @?= Just "rei"+            other -> assertFailure (show other),+      testCase "a Codex install refuses a skill directory without this tool's sidecar" $+        withPreparedKitHome $ \home _ -> do+          let path = home </> ".agents/skills/demo"+          createDirectoryIfMissing True path+          BS.writeFile (path </> "SKILL.md") "foreign\n"+          result <- installItem testConfig "demo" UserScope defaultInstallOptions+          assertKitError "shared name taken" isNameTaken result+          BS.readFile (path </> "SKILL.md") >>= (@?= "foreign\n")+          assertDirectoryMissing (ownedDemo home),+      testCase "the parser accepts --shared, --tool-only and --accept-shared-codex" $ do+        let parse = getParseResult . execParserPure defaultPrefs (info (kitCommandParser testConfig) mempty)+        parse ["install", "demo"] @?= Just (KitInstall (Just "demo") UserScope defaultInstallOptions)+        parse ["install", "demo", "--shared"] @?= Just (KitInstall (Just "demo") UserScope sharedOptions)+        parse ["install", "demo", "--tool-only"] @?= Just (KitInstall (Just "demo") UserScope (InstallOptions (Just ToolOnlyVisibility) False))+        parse ["install", "demo", "--accept-shared-codex"] @?= Just (KitInstall (Just "demo") UserScope (InstallOptions Nothing True))+        parse ["install", "demo", "--shared", "--tool-only"] @?= Nothing,+      testCase "an unknown visibility value is an invalid manifest" $+        withPreparedKitHome $ \_ cache -> do+          BS.writeFile (cache </> "kit.json") (Text.Encoding.encodeUtf8 (Text.replace "\"name\":\"demo\"" "\"visibility\":\"public\",\"name\":\"demo\"" (Text.Encoding.decodeUtf8 manifestJson)))+          assertKitError "invalid manifest" isInvalid =<< loadManifest cache,+      testCase "legacy visibility survives update until explicit reinstall" $+        withPreparedKitHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+          forM_ [ownedDemo home, home </> ".agents/skills/demo"] $ \dir -> do+            meta <- requireSidecar (dir </> ".testkit-kit.json")+            LBS.writeFile (dir </> ".testkit-kit.json") (Aeson.encode (meta & #visibility .~ Nothing & #visibilitySource .~ Nothing & #sharedLinks .~ Nothing & #codexDisabledSkills .~ Nothing))+          declareShared cache+          updateDemo cache+          assertDirectoryMissing (sharedDemo home)+          meta <- demoSidecar home+          meta ^. #visibility @?= Nothing+          legacy <- assertRight =<< checkVisibility testConfig InteractiveCodex UserScope SkillKind "demo"+          legacy ^. #effective @?= SharedVisibility+          _ <- assertRight =<< installItem testConfig "demo" UserScope (InstallOptions (Just ToolOnlyVisibility) False)+          modern <- assertRight =<< checkVisibility testConfig InteractiveCodex UserScope SkillKind "demo"+          modern ^. #requested @?= Just ToolOnlyVisibility+          modern ^. #effective @?= ToolOnlyVisibility,+      testCase "shared multi-file agents link the body and resources" $+        withPreparedKitHome $ \home cache -> do+          plantMultiFileAgent cache+          _ <- assertRight =<< installItem testConfig "reviewer" UserScope sharedOptions+          BS.readFile (home </> ".claude/agents/reviewer/guide.md") >>= (@?= "How to review.\n")+          assertBool "agent link" =<< pathIsSymbolicLink (home </> ".claude/agents/reviewer.md")+          outcomes <- assertRight =<< uninstallItem testConfig "reviewer" UserScope+          length (concatMap (view #linksRemoved) outcomes) @?= 2+    ]+  where+    isNameTaken KitSharedNameTaken {} = True+    isNameTaken _ = False+    isInvalid KitManifestInvalid {} = True+    isInvalid _ = False++sharedOptions :: InstallOptions+sharedOptions = InstallOptions (Just SharedVisibility) False++ownedDemo :: FilePath -> FilePath+ownedDemo home = home </> ".config/testkit/agents/.claude/skills/demo"++sharedDemo :: FilePath -> FilePath+sharedDemo home = home </> ".claude/skills/demo"++requireSidecar :: FilePath -> IO SidecarMeta+requireSidecar path = maybe (assertFailure ("missing sidecar: " <> path)) pure =<< readSidecar path++demoSidecar :: FilePath -> IO SidecarMeta+demoSidecar home = requireSidecar (ownedDemo home </> ".testkit-kit.json")++declareShared :: FilePath -> IO ()+declareShared cache = do+  bytes <- BS.readFile (cache </> "kit.json")+  BS.writeFile (cache </> "kit.json") (Text.Encoding.encodeUtf8 (Text.replace "\"name\":\"demo\"" "\"visibility\":\"shared\",\"name\":\"demo\"" (Text.Encoding.decodeUtf8 bytes)))++updateDemo :: FilePath -> IO ()+updateDemo cache = do+  manifest <- assertRight =<< loadManifest cache+  void (assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits)++codexVisibilityTests :: TestTree+codexVisibilityTests =+  testGroup+    "visibility"+    [ testCase "a tool-only Codex skill adds one disabled config entry" $+        withCodexHome $ \home _ -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          expected <- canonicalizePath (codexDemo home)+          entries <- assertRight =<< readSkillEntries (codexConfig home)+          entries @?= [(expected, False)]+          meta <- requireSidecar (takeDirectory (codexDemo home) </> ".testkit-kit.json")+          meta ^. #codexDisabledSkills @?= Just [Text.pack expected]+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          entries2 <- assertRight =<< readSkillEntries (codexConfig home)+          entries2 @?= entries,+      testCase "the config edit preserves the user's text and permissions" $+        withCodexHome $ \home _ -> do+          forM_ ["", "# no final newline", "# my config\n[projects.\"/x\"]\ntrust_level = \"trusted\"\n\n[[skills.config]]\npath = \"/user/other/SKILL.md\"\nenabled = false\n"] $ \seed -> do+            createDirectoryIfMissing True (home </> ".codex")+            BS.writeFile (codexConfig home) seed+            Posix.setFileMode (codexConfig home) 0o600+            _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+            infoBefore <- Posix.getFileStatus (codexConfig home)+            Posix.fileMode infoBefore .&. 0o777 @?= 0o600+            outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope+            expected <- canonicalizePath (codexDemo home)+            concatMap (view #configEntriesRemoved) outcomes @?= [expected]+            BS.readFile (codexConfig home) >>= (@?= seed),+      testCase "a shared Codex skill writes no entry and switching to shared removes ours" $+        withCodexHome $ \home _ -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+          assertFileMissing (codexConfig home)+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+          entries <- assertRight =<< readSkillEntries (codexConfig home)+          entries @?= [],+      testCase "codexSessionArgs re-enables exactly this tool's hidden skills" $+        withCodexHome $ \home _ -> do+          let root = takeDirectory home </> "project"+              config = testConfig & #projectRoot .~ pure root+          _ <- assertRight =<< installItem config "demo" UserScope defaultInstallOptions+          _ <- assertRight =<< installItem config "demo" ProjectScope defaultInstallOptions+          _ <- assertRight =<< addDisabledSkill (codexConfig home) (home </> "other/SKILL.md")+          user <- canonicalizePath (codexDemo home)+          project <- canonicalizePath (root </> ".agents/skills/demo/SKILL.md")+          args <- codexSessionArgs config+          parseArgs args @?= parseArgs (enableSkillsArgs [user, project])+          codexSessionArgs (config & #providers .~ [InteractiveClaude]) >>= (@?= []),+      testCase "a symlinked Codex config is refused untouched before writes" $+        withCodexHome $ \home _ -> do+          createDirectoryIfMissing True (home </> ".codex")+          BS.writeFile (home </> "generated.toml") "# generated\n"+          createFileLink (home </> "generated.toml") (codexConfig home)+          result <- installItem testConfig "demo" UserScope defaultInstallOptions+          case result of+            Left (KitCodexConfigUnusable _ message) -> assertBool "manual block is in error" ("[[skills.config]]" `Text.isInfixOf` message)+            other -> assertFailure (show other)+          assertDirectoryMissing (ownedDemo home)+          assertFileMissing (codexDemo home)+          getSymbolicLinkTarget (codexConfig home) >>= (@?= home </> "generated.toml")+          BS.readFile (home </> "generated.toml") >>= (@?= "# generated\n"),+      testCase "inline arrays, malformed TOML, and enabled=true are refused" $+        withCodexHome $ \home _ -> do+          createDirectoryIfMissing True (home </> ".codex")+          path <- canonicalizePath (codexDemo home)+          forM_ ["[skills]\nconfig = []\n", "invalid = [", "[[skills.config]]\npath = \"" <> Text.Encoding.encodeUtf8 (Text.pack path) <> "\"\nenabled = true\n"] $ \seed -> do+            BS.writeFile (codexConfig home) seed+            result <- installItem testConfig "demo" UserScope defaultInstallOptions+            assertKitError "unusable config" isConfigError result+            BS.readFile (codexConfig home) >>= (@?= seed)+            assertDirectoryMissing (ownedDemo home),+      testCase "an existing disabled entry is reused and survives uninstall" $+        withCodexHome $ \home _ -> do+          _ <- assertRight =<< addDisabledSkill (codexConfig home) (codexDemo home)+          seed <- BS.readFile (codexConfig home)+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          meta <- requireSidecar (takeDirectory (codexDemo home) </> ".testkit-kit.json")+          meta ^. #codexDisabledSkills @?= Just []+          args <- codexSessionArgs (testConfig & #projectRoot .~ pure (takeDirectory home </> "project"))+          path <- canonicalizePath (codexDemo home)+          parseArgs args @?= parseArgs (enableSkillsArgs [path])+          _ <- assertRight =<< uninstallItem testConfig "demo" UserScope+          BS.readFile (codexConfig home) >>= (@?= seed),+      testCase "update re-adds a deleted config entry including for local edits" $+        withCodexHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+          updateDemo cache+          entries <- assertRight =<< readSkillEntries (codexConfig home)+          assertBool "entry repaired" (length entries == 1)+          _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+          BS.writeFile (ownedDemo home </> "SKILL.md") "local edits\n"+          updateDemo cache+          entries2 <- assertRight =<< readSkillEntries (codexConfig home)+          entries2 @?= entries+          BS.readFile (ownedDemo home </> "SKILL.md") >>= (@?= "local edits\n"),+      testCase "a tool-only agent with Codex is refused without acceptance" $+        withCodexHome $ \home _ -> do+          result <- installItem testConfig "reviewer" UserScope defaultInstallOptions+          assertKitError "cannot isolate agent" (== KitCodexCannotIsolate "reviewer") result+          assertFileMissing (home </> ".config/testkit/agents/.claude/agents/reviewer.md")+          assertFileMissing (home </> ".codex/agents/reviewer.toml"),+      testCase "agent acceptance flag and callbacks work and update never asks" $+        withCodexHome $ \home cache -> do+          let refusing = testConfig & #confirmSharedCodex .~ Just (\_ -> pure False)+              accepting = testConfig & #confirmSharedCodex .~ Just (\_ -> pure True)+          result <- installItem refusing "reviewer" UserScope defaultInstallOptions+          assertKitError "callback refused" (== KitCodexCannotIsolate "reviewer") result+          _ <- assertRight =<< installItem accepting "reviewer" UserScope defaultInstallOptions+          manifest <- assertRight =<< loadManifest cache+          _ <- assertRight =<< reinstallPresent refusing cache manifest (Just "reviewer") KeepLocalEdits+          _ <- assertRight =<< uninstallItem testConfig "reviewer" UserScope+          _ <- assertRight =<< installItem refusing "reviewer" UserScope (InstallOptions Nothing True)+          assertFileExists (home </> ".codex/agents/reviewer.toml")+          _ <- assertRight =<< uninstallItem testConfig "reviewer" UserScope+          _ <- assertRight =<< installItem (refusing & #providers .~ [InteractiveClaude]) "reviewer" UserScope defaultInstallOptions+          assertFileMissing (home </> ".codex/agents/reviewer.toml"),+      testCase "the TOML escaper round-trips quotes, backslashes and controls" $+        withCodexHome $ \home _ -> do+          let path = home </> "quote\"slash\\tab\t/SKILL.md"+          _ <- assertRight =<< addDisabledSkill (codexConfig home) path+          expected <- canonicalizePath path+          entries <- assertRight =<< readSkillEntries (codexConfig home)+          entries @?= [(expected, False)]+          assertBool "launch override parses" (has _Right (parseArgs (enableSkillsArgs [expected]))),+      testCase "a tool-only Codex skill must list SKILL.md" $+        withCodexHome $ \home cache -> do+          BS.writeFile (cache </> "skills/demo/OTHER.md") "other"+          BS.writeFile (cache </> "kit.json") (Text.Encoding.encodeUtf8 (Text.replace "SKILL.md" "OTHER.md" (Text.Encoding.decodeUtf8 manifestJson)))+          assertKitError "no SKILL.md" isConfigError =<< installItem testConfig "demo" UserScope defaultInstallOptions+          assertDirectoryMissing (ownedDemo home)+    ]+  where+    isConfigError KitCodexConfigUnusable {} = True+    isConfigError _ = False++-- Every visibility test uses both isolated roots, as do the older fixtures.+withCodexHome :: (FilePath -> FilePath -> IO a) -> IO a+withCodexHome = withPreparedKitHome++codexConfig :: FilePath -> FilePath+codexConfig home = home </> ".codex/config.toml"++codexDemo :: FilePath -> FilePath+codexDemo home = home </> ".agents/skills/demo/SKILL.md"++parseArgs :: [Text] -> Either String Toml.Table+parseArgs ["-c", override] = Toml.forgetTableAnns <$> Toml.parse override+parseArgs other = Left ("unexpected arguments: " <> show other)++visibilityStatusTests :: TestTree+visibilityStatusTests =+  testGroup+    "visibility"+    [ testCase "status reports requested and effective visibility" $+        withStatusFixture $ \home _ config -> do+          rows <- collectStatus config (fixtureCache home) [(UserScope, "user")]+          let one n p = case filter (\r -> r ^. #name == n && r ^. #providers == p) rows of+                [r] -> pure r+                other -> assertFailure (show other)+          shared <- one "alpha" "claude"+          shared ^. #requestedVisibility @?= Just SharedVisibility+          shared ^. #effectiveVisibility @?= SharedVisibility+          hidden <- one "epsilon" "codex"+          hidden ^. #requestedVisibility @?= Just ToolOnlyVisibility+          hidden ^. #effectiveVisibility @?= ToolOnlyVisibility+          legacy <- one "gamma" "codex"+          legacy ^. #requestedVisibility @?= Nothing+          legacy ^. #effectiveVisibility @?= SharedVisibility+          broken <- one "gamma" "claude"+          broken ^. #requestedVisibility @?= Just SharedVisibility+          broken ^. #effectiveVisibility @?= ToolOnlyVisibility+          assertBool "broken condition" (KitVisibilityBroken `elem` (broken ^. #conditions))+          assertBool "table carries requested mismatch" ("tool-only (requested shared)" `Text.isInfixOf` renderStatusTable rows)+          assertBool "agent mismatch" ("shared (requested tool-only)" `Text.isInfixOf` renderStatusTable rows),+      testCase "a deleted link reports visibility-broken and update clears it" $+        withCodexHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+          removeFile (sharedDemo home)+          before <- assertRight =<< checkVisibility testConfig InteractiveClaude UserScope SkillKind "demo"+          before ^. #broken @?= [sharedDemo home]+          rows <- collectStatus testConfig cache [(UserScope, "user")]+          assertBool "status detects missing link" (any (elem KitVisibilityBroken . view #conditions) rows)+          updateDemo cache+          after <- assertRight =<< checkVisibility testConfig InteractiveClaude UserScope SkillKind "demo"+          after ^. #broken @?= [],+      testCase "a deleted disabled entry reports visibility-broken and update clears it" $+        withCodexHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+          rows <- collectStatus testConfig cache [(UserScope, "user")]+          assertBool "Codex is now shared and broken" (any (\r -> r ^. #providers == "codex" && r ^. #effectiveVisibility == SharedVisibility && KitVisibilityBroken `elem` (r ^. #conditions)) rows)+          updateDemo cache+          after <- assertRight =<< checkVisibility testConfig InteractiveCodex UserScope SkillKind "demo"+          after ^. #effective @?= ToolOnlyVisibility+          after ^. #broken @?= [],+      testCase "status table prints the legacy note only for a shared legacy Codex skill" $+        withStatusFixture $ \home _ config -> do+          rows <- collectStatus config (fixtureCache home) [(UserScope, "user")]+          assertBool "legacy note" ("installed before baikai-kit 0.4.0.0" `Text.isInfixOf` renderStatusTable rows)+          let modern = filter (\r -> has _Just (r ^. #requestedVisibility)) rows+          assertBool "no legacy note for modern copies" (not ("installed before baikai-kit 0.4.0.0" `Text.isInfixOf` renderStatusTable modern)),+      testCase "uninstall preserves foreign Codex assets and a replaced Claude link" $+        withCodexHome $ \home _ -> do+          _ <- assertRight =<< installItem (testConfig & #providers .~ [InteractiveClaude]) "demo" UserScope sharedOptions+          removeFile (sharedDemo home)+          createDirectoryIfMissing True (home </> "foreign")+          createDirectoryLink (home </> "foreign") (sharedDemo home)+          createDirectoryIfMissing True (takeDirectory (codexDemo home))+          BS.writeFile (codexDemo home) "foreign skill\n"+          outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope+          concatMap (view #linksRemoved) outcomes @?= []+          BS.readFile (codexDemo home) >>= (@?= "foreign skill\n")+          getSymbolicLinkTarget (sharedDemo home) >>= (@?= home </> "foreign")+    ]++visibilityRecoveryTests :: TestTree+visibilityRecoveryTests =+  testGroup+    "visibility"+    [ testCase "shared visibility refuses a user-owned disabled entry before writing assets" $+        withCodexHome $ \home _ -> do+          _ <- assertRight =<< addDisabledSkill (codexConfig home) (codexDemo home)+          seed <- BS.readFile (codexConfig home)+          result <- installItem testConfig "demo" UserScope sharedOptions+          case result of+            Left KitCodexConfigUnusable {} -> pure ()+            other -> assertFailure (show other)+          BS.readFile (codexConfig home) >>= (@?= seed)+          assertDirectoryMissing (ownedDemo home)+          assertFileMissing (codexDemo home),+      testCase "uninstall reports an owned dangling link after its copy and sidecar are lost" $+        withCodexHome $ \home _ -> do+          let config = testConfig & #providers .~ [InteractiveClaude]+          _ <- assertRight =<< installItem config "demo" UserScope sharedOptions+          removeDirectoryRecursive (ownedDemo home)+          outcomes <- assertRight =<< uninstallItem config "demo" UserScope+          concatMap (view #linksRemoved) outcomes @?= [sharedDemo home]+          assertBool "report names removed link" ("1 shared link" `Text.isInfixOf` renderUninstallReport "demo" UserScope outcomes),+      testCase "update does not introduce an unaccepted Codex agent copy" $+        withCodexHome $ \home cache -> do+          _ <- assertRight =<< installItem (testConfig & #providers .~ [InteractiveClaude]) "reviewer" UserScope defaultInstallOptions+          manifest <- assertRight =<< loadManifest cache+          _ <- assertRight =<< reinstallPresent testConfig cache manifest (Just "reviewer") KeepLocalEdits+          assertFileMissing (home </> ".codex/agents/reviewer.toml"),+      testCase "multiple config entries preserve each other when one is removed" $+        withCodexHome $ \home _ -> do+          _ <- assertRight =<< addDisabledSkill (codexConfig home) (codexDemo home)+          _ <- assertRight =<< addDisabledSkill (codexConfig home) (home </> "other/SKILL.md")+          _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+          expected <- canonicalizePath (home </> "other/SKILL.md")+          entries <- assertRight =<< readSkillEntries (codexConfig home)+          entries @?= [(expected, False)],+      testCase "update finishes pending visibility removals from sidecar ownership" $+        withCodexHome $ \home cache -> do+          _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+          meta <- demoSidecar home+          LBS.writeFile (ownedDemo home </> ".testkit-kit.json") (Aeson.encode (meta & #visibility .~ Just "tool-only"))+          before <- assertRight =<< checkVisibility testConfig InteractiveClaude UserScope SkillKind "demo"+          before ^. #broken @?= [sharedDemo home]+          updateDemo cache+          assertDirectoryMissing (sharedDemo home)+          _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+          let sidecar = takeDirectory (codexDemo home) </> ".testkit-kit.json"+          codexMeta <- requireSidecar sidecar+          LBS.writeFile sidecar (Aeson.encode (codexMeta & #visibility .~ Just "shared" & #visibilitySource .~ Just "install-flag"))+          pending <- assertRight =<< checkVisibility testConfig InteractiveCodex UserScope SkillKind "demo"+          assertBool "pending config cleanup" (not (null (pending ^. #broken)))+          updateDemo cache+          entries <- assertRight =<< readSkillEntries (codexConfig home)+          entries @?= []+          final <- requireSidecar sidecar+          final ^. #codexDisabledSkills @?= Just [],+      testCase "removal tolerates deleted delimiter comments and keeps other TOML" $+        withCodexHome $ \home _ -> do+          path <- canonicalizePath (codexDemo home)+          createDirectoryIfMissing True (home </> ".codex")+          let suffix = "[projects.\"/x\"]\ntrust_level = \"trusted\"\n"+              seed = "# user comment\n[[skills.config]]\npath = \"" <> Text.Encoding.encodeUtf8 (Text.pack path) <> "\"\nenabled = false\n" <> suffix+          BS.writeFile (codexConfig home) seed+          removed <- assertRight =<< removeDisabledSkill (codexConfig home) path+          removed @?= True+          BS.readFile (codexConfig home) >>= (@?= "# user comment\n" <> suffix),+      testCase "a read-only Codex config is refused before writing assets" $+        withCodexHome $ \home _ -> do+          createDirectoryIfMissing True (home </> ".codex")+          BS.writeFile (codexConfig home) "# read only\n"+          Posix.setFileMode (codexConfig home) 0o400+          result <- installItem testConfig "demo" UserScope defaultInstallOptions+          case result of+            Left KitCodexConfigUnusable {} -> pure ()+            other -> assertFailure (show other)+          assertDirectoryMissing (ownedDemo home)+          BS.readFile (codexConfig home) >>= (@?= "# read only\n"),+      testCase "a read-only Codex config parent is refused before writing assets" $+        withCodexHome $ \home _ -> do+          let parent = home </> ".codex"+          createDirectoryIfMissing True parent+          Posix.setFileMode parent 0o500+          flip finally (Posix.setFileMode parent 0o700) $ do+            result <- installItem testConfig "demo" UserScope defaultInstallOptions+            case result of+              Left KitCodexConfigUnusable {} -> pure ()+              other -> assertFailure (show other)+            assertDirectoryMissing (ownedDemo home)+            assertFileMissing (codexConfig home),+      testCase "foreign Codex agent resources are protected without a body file" $+        withCodexHome $ \home _ -> do+          let resource = home </> ".codex/agents/reviewer/data.txt"+          createDirectoryIfMissing True (takeDirectory resource)+          BS.writeFile resource "foreign resource"+          result <- installItem testConfig "reviewer" UserScope sharedOptions+          case result of+            Left KitSharedNameTaken {} -> pure ()+            other -> assertFailure (show other)+          BS.readFile resource >>= (@?= "foreign resource")+          assertFileMissing (home </> ".codex/agents/reviewer.toml")+          assertDirectoryMissing (ownedDemo home),+      testCase "project shared names and foreign Codex agents are protected" $+        withCodexHome $ \home _ -> do+          let root = takeDirectory home </> "project"+              config = testConfig & #projectRoot .~ pure root+          createDirectoryIfMissing True (root </> ".claude/skills/demo")+          result <- installItem config "demo" ProjectScope sharedOptions+          case result of+            Left KitSharedNameTaken {} -> pure ()+            other -> assertFailure (show other)+          assertDirectoryMissing (root </> ".testkit")+          createDirectoryIfMissing True (home </> ".codex/agents")+          BS.writeFile (home </> ".codex/agents/reviewer.toml") "foreign"+          result2 <- installItem testConfig "reviewer" UserScope sharedOptions+          case result2 of+            Left KitSharedNameTaken {} -> pure ()+            other -> assertFailure (show other)+          BS.readFile (home </> ".codex/agents/reviewer.toml") >>= (@?= "foreign"),+      testCase "post-content visibility failure remains tracked and update repairs it" $+        withCodexHome $ \home cache -> do+          let config = testConfig & #providers .~ [InteractiveClaude]+          createDirectoryIfMissing True (home </> ".claude")+          BS.writeFile (home </> ".claude/skills") "blocking parent"+          result <- installItem config "demo" UserScope sharedOptions+          case result of+            Left KitVisibilityNotApplied {} -> pure ()+            other -> assertFailure (show other)+          meta <- demoSidecar home+          meta ^. #sharedLinks @?= Just [Text.pack (sharedDemo home)]+          assertFileExists (ownedDemo home </> "SKILL.md")+          removeFile (home </> ".claude/skills")+          manifest <- assertRight =<< loadManifest cache+          _ <- assertRight =<< reinstallPresent config cache manifest (Just "demo") KeepLocalEdits+          assertBool "repair after blocker clears" =<< pathIsSymbolicLink (sharedDemo home)+    ]
+ test/fixtures/mori-kit.json view
@@ -0,0 +1,34 @@+{+  "version": 2,+  "skills": [+    {+      "name": "automation-config",+      "version": "0.1.0",+      "description": "Author, validate, and debug mori automation configurations",+      "path": "skills/automation-config",+      "files": ["SKILL.md"]+    },+    {+      "name": "mori-config",+      "version": "0.1.0",+      "description": "Author, validate, and edit mori.dhall project configurations",+      "path": "skills/mori-config",+      "files": ["SKILL.md"]+    },+    {+      "name": "cookbook-config",+      "version": "0.1.0",+      "description": "Author, validate, and edit mori/cookbook.dhall cookbook catalogs",+      "path": "skills/cookbook-config",+      "files": ["SKILL.md"]+    },+    {+      "name": "mori-bootstrap-corpus",+      "version": "0.1.0",+      "description": "Bootstrap a complete corpus project from a repo name",+      "path": "skills/mori-bootstrap-corpus",+      "files": ["SKILL.md"]+    }+  ],+  "agents": []+}
+ test/fixtures/rei-kit.json view
@@ -0,0 +1,67 @@+{+  "version": 1,+  "skills": [+    {+      "name": "rei-bootstrap",+      "description": "Interactively bootstrap intentions, habits, and reflections for Rei personal coaching",+      "path": "skills/rei-bootstrap",+      "files": ["SKILL.md"]+    },+    {+      "name": "rei-bootstrap-habit",+      "description": "Interactively bootstrap a new habit for Rei personal coaching",+      "path": "skills/rei-bootstrap-habit",+      "files": ["SKILL.md"]+    },+    {+      "name": "rei-summarize-links",+      "description": "Extract markdown links from a document, summarize each URL, create Rei notes with summaries, and connect them to links via edges",+      "path": "skills/rei-summarize-links",+      "files": ["SKILL.md"]+    },+    {+      "name": "rei-scaffold-kit-skill",+      "description": "Scaffold new Claude Code skills and agents for Rei, following established conventions from the rei codebase",+      "path": "skills/rei-scaffold-kit-skill",+      "files": ["SKILL.md"]+    },+    {+      "name": "rei-ingest-url",+      "description": "Ingest a single URL into Rei — reuse or create a link, summarize the content into a note, connect them with a summarizes edge, and classify the link with author-type, content-type, media, platform, and reused/new topical tags",+      "path": "skills/rei-ingest-url",+      "files": ["SKILL.md"]+    },+    {+      "name": "rei-collection-epub",+      "description": "Export a Rei collection to EPUB format using pandoc, with automatic chapter ordering inferred from titles, edges, content analysis, and timestamps",+      "path": "skills/rei-collection-epub",+      "files": ["SKILL.md"]+    },+    {+      "name": "rei-ingest-url-collection",+      "description": "Read a markdown file of links, run the full rei-ingest-url workflow for each URL, and gather every resulting summary note into a single Rei collection",+      "path": "skills/rei-ingest-url-collection",+      "files": ["SKILL.md"]+    },+    {+      "name": "rei-complete-from-commits",+      "description": "Backlog cleanup — for a given Rei intention and git repository, find commits whose Intention: trailer matches the intention's children and bulk-complete each with --at set to the latest matching commit timestamp; optionally infer dates for children with no trailer matches from pre-trailer commit subjects",+      "path": "skills/rei-complete-from-commits",+      "files": ["SKILL.md"]+    },+    {+      "name": "rei-ingest-markdown",+      "description": "Ingest a pre-converted markdown file (with frontmatter) into Rei — reuse or create a link from the source URL, save the full markdown as an archive note, summarize it into a separate summary note, wire them with `archives` and `summarizes` edges, and classify the link with author-type, content-type, media, platform, and reused/new topical tags",+      "path": "skills/rei-ingest-markdown",+      "files": ["SKILL.md"]+    }+  ],+  "agents": [+    {+      "name": "rei-custom-property-guide",+      "description": "Guide users through creating and managing custom properties in Rei. Helps design property schemas, choose value types, configure category scopes, and create state machine workflows.",+      "path": "agents",+      "files": ["rei-custom-property-guide.md"]+    }+  ]+}
+ test/fixtures/seihou-kit.json view
@@ -0,0 +1,18 @@+{+  "version": 1,+  "skills": [+    {+      "name": "seihou-scaffold-kit-skill",+      "description": "Scaffold new Claude Code skills and agents for Seihou, following established conventions.",+      "path": "skills/seihou-scaffold-kit-skill",+      "files": ["SKILL.md"]+    },+    {+      "name": "seihou-module-readme",+      "description": "Generate or refresh a human-readable README.md for a Seihou module, documenting its version, variables, prompts, dependencies, exports, generated files, and usage.",+      "path": "skills/seihou-module-readme",+      "files": ["SKILL.md"]+    }+  ],+  "agents": []+}
+ test/golden/list.json view
@@ -0,0 +1,1 @@+{"document":"kit-list","formatVersion":1,"items":[{"description":"The alpha skill","installed":[{"path":"$HOME/.config/testkit/agents/.claude/skills/alpha","provider":"claude","scope":"user","version":"0.1.0"},{"path":"$HOME/.agents/skills/alpha","provider":"codex","scope":"user","version":null}],"kind":"skill","name":"alpha","version":"0.1.0","visibility":"shared"},{"description":"The beta skill","installed":[{"path":"$PROJECT/.testkit/agents/.claude/skills/beta","provider":"claude","scope":"project","version":"0.1.0"},{"path":"$PROJECT/.agents/skills/beta","provider":"codex","scope":"project","version":"0.1.0"}],"kind":"skill","name":"beta","version":"0.2.0","visibility":"tool-only"},{"description":"The gamma skill","installed":[{"path":"$HOME/.config/testkit/agents/.claude/skills/gamma","provider":"claude","scope":"user","version":"0.1.0"},{"path":"$HOME/.agents/skills/gamma","provider":"codex","scope":"user","version":"0.1.0"}],"kind":"skill","name":"gamma","version":"0.1.0","visibility":"shared"},{"description":"The epsilon skill","installed":[{"path":"$HOME/.config/testkit/agents/.claude/skills/epsilon","provider":"claude","scope":"user","version":"0.1.0"},{"path":"$HOME/.agents/skills/epsilon","provider":"codex","scope":"user","version":"0.1.0"}],"kind":"skill","name":"epsilon","version":"0.1.0","visibility":"tool-only"},{"description":"The reviewer agent","installed":[{"path":"$HOME/.config/testkit/agents/.claude/agents/reviewer.md","provider":"claude","scope":"user","version":"0.1.0"},{"path":"$HOME/.codex/agents/reviewer.toml","provider":"codex","scope":"user","version":"0.1.0"}],"kind":"agent","name":"reviewer","version":"0.1.0","visibility":"tool-only"},{"description":"The planner agent","installed":[{"path":"$PROJECT/.testkit/agents/.claude/agents/planner.md","provider":"claude","scope":"project","version":"0.1.0"},{"path":"$PROJECT/.codex/agents/planner.toml","provider":"codex","scope":"project","version":"0.1.0"}],"kind":"agent","name":"planner","version":"0.1.0","visibility":"tool-only"}],"upstream":{"detail":"<detail>","state":"stale"}}
+ test/golden/status.json view
@@ -0,0 +1,1 @@+{"document":"kit-status","formatVersion":1,"items":[{"conditions":[],"effectiveVisibility":"shared","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"alpha","provider":"claude","requestedVisibility":"shared","scope":"user","upToDate":true},{"conditions":["unknown"],"effectiveVisibility":"shared","installedVersion":null,"kind":"skill","latestVersion":"0.1.0","name":"alpha","provider":"codex","requestedVisibility":null,"scope":"user","upToDate":false},{"conditions":["outdated"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.2.0","name":"beta","provider":"claude","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":["outdated"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.2.0","name":"beta","provider":"codex","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":["delisted"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":null,"name":"delta","provider":"claude","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":["delisted"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":null,"name":"delta","provider":"codex","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":["refused"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"epsilon","provider":"claude","requestedVisibility":"tool-only","scope":"user","upToDate":false},{"conditions":["refused"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"epsilon","provider":"codex","requestedVisibility":"tool-only","scope":"user","upToDate":false},{"conditions":["changed-upstream","visibility-broken"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"gamma","provider":"claude","requestedVisibility":"shared","scope":"user","upToDate":false},{"conditions":["changed-upstream"],"effectiveVisibility":"shared","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"gamma","provider":"codex","requestedVisibility":null,"scope":"user","upToDate":false},{"conditions":["edits-unknown"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"agent","latestVersion":"0.1.0","name":"planner","provider":"claude","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":[],"effectiveVisibility":"shared","installedVersion":"0.1.0","kind":"agent","latestVersion":"0.1.0","name":"planner","provider":"codex","requestedVisibility":"tool-only","scope":"project","upToDate":true},{"conditions":["modified"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"agent","latestVersion":"0.1.0","name":"reviewer","provider":"claude","requestedVisibility":"tool-only","scope":"user","upToDate":false},{"conditions":[],"effectiveVisibility":"shared","installedVersion":"0.1.0","kind":"agent","latestVersion":"0.1.0","name":"reviewer","provider":"codex","requestedVisibility":"tool-only","scope":"user","upToDate":true}],"upstream":{"detail":"<detail>","state":"stale"}}
+ test/golden/update.json view
@@ -0,0 +1,1 @@+{"document":"kit-update","formatVersion":1,"refresh":null,"skipped":[{"name":"demo","reason":"locally-modified","scope":"user"}],"updated":[{"name":"reviewer","scope":"user"}]}