baikai-kit 0.3.0.0 → 0.4.0.0
raw patch · 22 files changed
+1832/−111 lines, 22 filesdep +containersdep +toml-parserdep +unixPVP ok
version bump matches the API change (PVP)
Dependencies added: containers, toml-parser, unix
API changes (from Hackage documentation)
+ Baikai.Kit.CodexConfig: addDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)
+ Baikai.Kit.CodexConfig: checkDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)
+ Baikai.Kit.CodexConfig: checkRemoveDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)
+ Baikai.Kit.CodexConfig: codexConfigPath :: IO FilePath
+ Baikai.Kit.CodexConfig: enableSkillsArgs :: [FilePath] -> [Text]
+ Baikai.Kit.CodexConfig: readSkillEntries :: FilePath -> IO (Either KitError [(FilePath, Bool)])
+ Baikai.Kit.CodexConfig: removeDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)
+ Baikai.Kit.Config: [confirmSharedCodex] :: KitConfig -> !Maybe (KitItem -> IO Bool)
+ Baikai.Kit.Config: sharedClaudeBase :: KitConfig -> KitScope -> IO FilePath
+ Baikai.Kit.Error: KitCodexCannotIsolate :: Text -> KitError
+ Baikai.Kit.Error: KitCodexConfigUnusable :: FilePath -> Text -> KitError
+ Baikai.Kit.Error: KitSharedNameTaken :: FilePath -> Maybe Text -> KitError
+ Baikai.Kit.Error: KitVisibilityNotApplied :: Text -> [FilePath] -> KitError
+ Baikai.Kit.Install: InstallOptions :: !Maybe KitVisibility -> !Bool -> InstallOptions
+ Baikai.Kit.Install: VisibilityCheck :: !Maybe KitVisibility -> !KitVisibility -> ![FilePath] -> VisibilityCheck
+ Baikai.Kit.Install: [acceptSharedCodex] :: InstallOptions -> !Bool
+ Baikai.Kit.Install: [broken] :: VisibilityCheck -> ![FilePath]
+ Baikai.Kit.Install: [configEntriesRemoved] :: RemovalOutcome -> ![FilePath]
+ Baikai.Kit.Install: [effective] :: VisibilityCheck -> !KitVisibility
+ Baikai.Kit.Install: [linksRemoved] :: RemovalOutcome -> ![FilePath]
+ Baikai.Kit.Install: [requested] :: VisibilityCheck -> !Maybe KitVisibility
+ Baikai.Kit.Install: [visibility] :: InstallOptions -> !Maybe KitVisibility
+ Baikai.Kit.Install: checkVisibility :: KitConfig -> AgentAssetProvider -> KitScope -> KitItemKind -> Text -> IO (Either KitError VisibilityCheck)
+ Baikai.Kit.Install: checkVisibilityWithEntries :: Either KitError [(FilePath, Bool)] -> KitConfig -> AgentAssetProvider -> KitScope -> KitItemKind -> Text -> IO (Either KitError VisibilityCheck)
+ Baikai.Kit.Install: data InstallOptions
+ Baikai.Kit.Install: data VisibilityCheck
+ Baikai.Kit.Install: defaultInstallOptions :: InstallOptions
+ Baikai.Kit.Install: instance GHC.Classes.Eq Baikai.Kit.Install.InstallOptions
+ Baikai.Kit.Install: instance GHC.Classes.Eq Baikai.Kit.Install.VisibilityCheck
+ Baikai.Kit.Install: instance GHC.Internal.Generics.Generic Baikai.Kit.Install.InstallOptions
+ Baikai.Kit.Install: instance GHC.Internal.Generics.Generic Baikai.Kit.Install.ProviderVisibility
+ Baikai.Kit.Install: instance GHC.Internal.Generics.Generic Baikai.Kit.Install.VisibilityCheck
+ Baikai.Kit.Install: instance GHC.Internal.Show.Show Baikai.Kit.Install.InstallOptions
+ Baikai.Kit.Install: instance GHC.Internal.Show.Show Baikai.Kit.Install.VisibilityCheck
+ Baikai.Kit.Install: relativeLinkTarget :: FilePath -> FilePath -> FilePath
+ Baikai.Kit.Manifest: [visibility] :: SkillEntry -> !Maybe KitVisibility
+ Baikai.Kit.Manifest: itemVisibility :: KitItem -> KitVisibility
+ Baikai.Kit.Session: codexSessionArgs :: KitConfig -> IO [Text]
+ Baikai.Kit.Sidecar: [codexDisabledSkills] :: SidecarMeta -> !Maybe [Text]
+ Baikai.Kit.Sidecar: [sharedLinks] :: SidecarMeta -> !Maybe [Text]
+ Baikai.Kit.Sidecar: [visibilitySource] :: SidecarMeta -> !Maybe Text
+ Baikai.Kit.Sidecar: [visibility] :: SidecarMeta -> !Maybe Text
+ Baikai.Kit.Status: KitVisibilityBroken :: KitCondition
+ Baikai.Kit.Status: [effectiveVisibility] :: StatusRow -> !KitVisibility
+ Baikai.Kit.Status: [requestedVisibility] :: StatusRow -> !Maybe KitVisibility
+ Baikai.Kit.Visibility: FromInstallFlag :: VisibilitySource
+ Baikai.Kit.Visibility: FromManifest :: VisibilitySource
+ Baikai.Kit.Visibility: SharedVisibility :: KitVisibility
+ Baikai.Kit.Visibility: ToolOnlyVisibility :: KitVisibility
+ Baikai.Kit.Visibility: data KitVisibility
+ Baikai.Kit.Visibility: data VisibilitySource
+ Baikai.Kit.Visibility: instance Data.Aeson.Types.FromJSON.FromJSON Baikai.Kit.Visibility.KitVisibility
+ Baikai.Kit.Visibility: instance GHC.Classes.Eq Baikai.Kit.Visibility.KitVisibility
+ Baikai.Kit.Visibility: instance GHC.Classes.Eq Baikai.Kit.Visibility.VisibilitySource
+ Baikai.Kit.Visibility: instance GHC.Classes.Ord Baikai.Kit.Visibility.KitVisibility
+ Baikai.Kit.Visibility: instance GHC.Internal.Enum.Bounded Baikai.Kit.Visibility.KitVisibility
+ Baikai.Kit.Visibility: instance GHC.Internal.Enum.Enum Baikai.Kit.Visibility.KitVisibility
+ Baikai.Kit.Visibility: instance GHC.Internal.Show.Show Baikai.Kit.Visibility.KitVisibility
+ Baikai.Kit.Visibility: instance GHC.Internal.Show.Show Baikai.Kit.Visibility.VisibilitySource
+ Baikai.Kit.Visibility: parseVisibility :: Text -> Either Text KitVisibility
+ Baikai.Kit.Visibility: visibilityLabel :: KitVisibility -> Text
- Baikai.Kit.Command: KitInstall :: !Maybe Text -> !KitScope -> KitCommand
+ Baikai.Kit.Command: KitInstall :: !Maybe Text -> !KitScope -> !InstallOptions -> KitCommand
- Baikai.Kit.Config: KitConfig :: !Text -> !Text -> ![AgentAssetProvider] -> !IO FilePath -> !Maybe (KitManifest -> IO (Maybe Text)) -> KitConfig
+ Baikai.Kit.Config: KitConfig :: !Text -> !Text -> ![AgentAssetProvider] -> !IO FilePath -> !Maybe (KitManifest -> IO (Maybe Text)) -> !Maybe (KitItem -> IO Bool) -> KitConfig
- Baikai.Kit.Install: RemovalOutcome :: !InteractiveProvider -> !Bool -> !Bool -> !Bool -> RemovalOutcome
+ Baikai.Kit.Install: RemovalOutcome :: !InteractiveProvider -> !Bool -> !Bool -> !Bool -> ![FilePath] -> ![FilePath] -> RemovalOutcome
- Baikai.Kit.Install: installFrom :: KitConfig -> FilePath -> KitManifest -> Text -> KitScope -> IO (Either KitError KitItem)
+ Baikai.Kit.Install: installFrom :: KitConfig -> FilePath -> KitManifest -> Text -> KitScope -> InstallOptions -> IO (Either KitError KitItem)
- Baikai.Kit.Install: installItem :: KitConfig -> Text -> KitScope -> IO (Either KitError KitItem)
+ Baikai.Kit.Install: installItem :: KitConfig -> Text -> KitScope -> InstallOptions -> IO (Either KitError KitItem)
- Baikai.Kit.Manifest: AgentEntry :: !Text -> !Text -> !Maybe Text -> !Text -> !Maybe [Text] -> AgentEntry
+ Baikai.Kit.Manifest: AgentEntry :: !Text -> !Text -> !Maybe Text -> !Text -> !Maybe [Text] -> !Maybe KitVisibility -> AgentEntry
- Baikai.Kit.Manifest: SkillEntry :: !Text -> !Text -> !Maybe Text -> !Text -> ![Text] -> SkillEntry
+ Baikai.Kit.Manifest: SkillEntry :: !Text -> !Text -> !Maybe Text -> !Text -> ![Text] -> !Maybe KitVisibility -> SkillEntry
- Baikai.Kit.Sidecar: SidecarMeta :: !Text -> !Text -> !Maybe Text -> !Text -> !Text -> !Maybe [Text] -> !Maybe Text -> SidecarMeta
+ Baikai.Kit.Sidecar: SidecarMeta :: !Text -> !Text -> !Maybe Text -> !Text -> !Text -> !Maybe [Text] -> !Maybe Text -> !Maybe Text -> !Maybe Text -> !Maybe [Text] -> !Maybe [Text] -> SidecarMeta
- Baikai.Kit.Sidecar: newSidecarMeta :: KitItem -> Text -> [Text] -> Text -> IO SidecarMeta
+ Baikai.Kit.Sidecar: newSidecarMeta :: KitItem -> Text -> [Text] -> Text -> Maybe Text -> Maybe Text -> [Text] -> [Text] -> IO SidecarMeta
- Baikai.Kit.Status: StatusRow :: !Text -> !Text -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> ![KitCondition] -> StatusRow
+ Baikai.Kit.Status: StatusRow :: !Text -> !Text -> !Text -> !Text -> !Maybe Text -> !Maybe Text -> ![KitCondition] -> !Maybe KitVisibility -> !KitVisibility -> StatusRow
Files
- CHANGELOG.md +117/−0
- baikai-kit.cabal +11/−1
- src/Baikai/Kit.hs +4/−0
- src/Baikai/Kit/CodexConfig.hs +298/−0
- src/Baikai/Kit/Command.hs +18/−9
- src/Baikai/Kit/Config.hs +14/−3
- src/Baikai/Kit/Error.hs +15/−0
- src/Baikai/Kit/Install.hs +352/−29
- src/Baikai/Kit/Json.hs +9/−4
- src/Baikai/Kit/Link.hs +129/−0
- src/Baikai/Kit/Manifest.hs +10/−2
- src/Baikai/Kit/Session.hs +31/−3
- src/Baikai/Kit/Sidecar.hs +12/−4
- src/Baikai/Kit/Status.hs +45/−6
- src/Baikai/Kit/Visibility.hs +30/−0
- test/Main.hs +615/−50
- test/fixtures/mori-kit.json +34/−0
- test/fixtures/rei-kit.json +67/−0
- test/fixtures/seihou-kit.json +18/−0
- test/golden/list.json +1/−0
- test/golden/status.json +1/−0
- test/golden/update.json +1/−0
CHANGELOG.md view
@@ -7,6 +7,123 @@ ## [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
baikai-kit.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.4 name: baikai-kit-version: 0.3.0.0+version: 0.4.0.0 synopsis: Shared kit installer for AI-agent skills and subagents description: Shared implementation of kit listing, installation, update, uninstall,@@ -16,6 +16,9 @@ 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:@@ -58,6 +61,7 @@ hs-source-dirs: src exposed-modules: Baikai.Kit+ Baikai.Kit.CodexConfig Baikai.Kit.Command Baikai.Kit.Config Baikai.Kit.Error@@ -69,13 +73,16 @@ 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,+ containers ^>=0.7, crypton ^>=1.0, directory ^>=1.3, filepath ^>=1.5,@@ -83,6 +90,7 @@ process ^>=1.6, text ^>=2.1, time ^>=1.14,+ toml-parser ^>=2.0.2, test-suite baikai-kit-test import: common-options@@ -107,3 +115,5 @@ tasty-hunit, temporary, text,+ toml-parser,+ unix,
src/Baikai/Kit.hs view
@@ -1,6 +1,7 @@ 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,@@ -10,9 +11,11 @@ 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@@ -24,3 +27,4 @@ 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
@@ -15,7 +15,8 @@ import Baikai.Kit.Config (KitConfig, KitScope (..), scopeLabel) import Baikai.Kit.Error (KitError (..), renderKitError) import Baikai.Kit.Install- ( OverwritePolicy (..),+ ( InstallOptions (..),+ OverwritePolicy (..), UpdateReport, installFrom, loadManifest,@@ -28,6 +29,7 @@ import Baikai.Kit.Manifest (KitManifest, itemKind, itemName) import Baikai.Kit.Repo (KitRepo, RepoRefresh (..), ensureKitRepo) import Baikai.Kit.Status (StatusReport, UpstreamAvailability (..), installedCopies, kitStatus, renderStatusTable)+import Baikai.Kit.Visibility (KitVisibility (..)) import Baikai.Prelude import Data.Aeson (Value) import Data.Aeson qualified as Aeson@@ -48,7 +50,7 @@ data KitCommand = KitList !OutputFormat | -- | 'Nothing' asks the configured 'Baikai.Kit.Config.chooseItem'.- KitInstall !(Maybe Text) !KitScope+ KitInstall !(Maybe Text) !KitScope !InstallOptions | KitUpdate !(Maybe Text) !OverwritePolicy !OutputFormat | KitUninstall !Text !KitScope | KitStatus !OutputFormat@@ -78,19 +80,19 @@ loadManifest (repo ^. #dir) `thenE` \manifest -> do copies <- installedCopies config emit (listDocument (repoAvailability repo) manifest copies)- KitInstall (Just n) scope -> withRepo HumanOutput $ \repo ->+ KitInstall (Just n) scope options -> withRepo HumanOutput $ \repo -> loadManifest (repo ^. #dir) `thenE` \manifest ->- installNamed repo manifest n scope+ 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 -> case config ^. #chooseItem of+ 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+ Just n -> installNamed repo manifest n scope options KitUpdate n policy HumanOutput -> updateKit config n policy `thenE` (printed . renderUpdateReport) KitUpdate n policy JsonOutput ->@@ -104,9 +106,9 @@ HumanOutput -> printed (renderStatusTable (report ^. #rows)) JsonOutput -> emit (statusDocument report) where- installNamed :: KitRepo -> KitManifest -> Text -> KitScope -> IO (Either KitError ())- installNamed repo manifest n scope =- installFrom config (repo ^. #dir) manifest n scope `thenE` \item ->+ 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." @@ -215,6 +217,13 @@ ) ) <*> 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 =
src/Baikai/Kit/Config.hs view
@@ -9,6 +9,7 @@ projectAgentsDir, resolveAgentsBase, providerAgentsBase,+ sharedClaudeBase, providerLabel, sidecarFileName, scopeLabel,@@ -17,7 +18,7 @@ import Baikai.AgentAssets (AgentAssetProvider) import Baikai.Interactive (InteractiveProvider (..))-import Baikai.Kit.Manifest (KitManifest)+import Baikai.Kit.Manifest (KitItem, KitManifest) import Baikai.Prelude import Data.Maybe (fromMaybe) import Data.Text qualified as Text@@ -46,7 +47,9 @@ -- '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)))+ 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) @@ -61,6 +64,8 @@ . 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:@@ -73,7 +78,8 @@ repoUrl, providers, projectRoot = getCurrentDirectory,- chooseItem = Nothing+ chooseItem = Nothing,+ confirmSharedCodex = Nothing } -- | The nearest directory, starting at @start@ and walking towards the@@ -139,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
@@ -53,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) @@ -94,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,@@ -38,8 +44,10 @@ 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,@@ -49,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@@ -72,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@@ -92,7 +113,9 @@ { provider :: !InteractiveProvider, skillRemoved :: !Bool, agentRemoved :: !Bool,- sidecarRemoved :: !Bool+ sidecarRemoved :: !Bool,+ linksRemoved :: ![FilePath],+ configEntriesRemoved :: ![FilePath] } deriving stock (Eq, Generic, Show) @@ -135,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.@@ -154,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@@ -180,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]@@ -380,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@@ -408,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@@ -476,11 +535,263 @@ 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@@ -490,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@@ -502,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@@ -591,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
@@ -30,9 +30,11 @@ 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@@ -48,22 +50,23 @@ "document" .= ("kit-list" :: Text), "upstream" .= upstreamValue availability, "items"- .= ( [ itemValue SkillKind (entry ^. #name) (entry ^. #description) (entry ^. #version)+ .= ( [ itemValue SkillKind (entry ^. #name) (entry ^. #description) (entry ^. #version) (visibilityLabel (fromMaybe ToolOnlyVisibility (entry ^. #visibility))) | entry <- manifest ^. #skills ]- ++ [ itemValue AgentKind (entry ^. #name) (entry ^. #description) (entry ^. #version)+ ++ [ itemValue AgentKind (entry ^. #name) (entry ^. #description) (entry ^. #version) (visibilityLabel (fromMaybe ToolOnlyVisibility (entry ^. #visibility))) | entry <- manifest ^. #agents ] ) ] where- itemValue :: KitItemKind -> Text -> Text -> Maybe Text -> Value- itemValue kind n description version =+ 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@@ -102,6 +105,8 @@ "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) ]
+ 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
@@ -15,13 +15,15 @@ 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 (LocalEdits (..), checkLocalEdits, 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)@@ -53,6 +55,7 @@ | -- | @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.@@ -82,7 +85,9 @@ installedVersion :: !(Maybe Text), latestVersion :: !(Maybe Text), -- | Sorted and duplicate-free; empty means up to date.- conditions :: ![KitCondition]+ conditions :: ![KitCondition],+ requestedVisibility :: !(Maybe KitVisibility),+ effectiveVisibility :: !KitVisibility } deriving stock (Eq, Generic, Show) @@ -97,6 +102,7 @@ KitChangedUpstream -> "changed-upstream" KitLocallyModified -> "modified" KitLocalEditsUnknown -> "edits-unknown"+ KitVisibilityBroken -> "visibility-broken" -- | @up-to-date@ for no conditions; otherwise the labels in constructor -- order joined with @+@, e.g. @outdated+changed-upstream+modified@.@@ -147,6 +153,10 @@ -- 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@@ -165,6 +175,10 @@ 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',@@ -173,7 +187,9 @@ providers = providerLabel provider, installedVersion = mSidecar >>= (^. #version), latestVersion = mItem >>= itemVersion,- conditions = sort (nub (upstreamConditions ++ localConditions))+ conditions = sort (nub (upstreamConditions ++ localConditions ++ visibilityConditions)),+ requestedVisibility,+ effectiveVisibility } -- | One item at one scope for one provider, and where it is.@@ -260,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 =@@ -274,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"@@ -281,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 =@@ -288,6 +324,7 @@ <> 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)) <> renderConditions (row ^. #conditions)@@ -303,7 +340,9 @@ row ^. #scope, row ^. #installedVersion, row ^. #latestVersion,- row ^. #conditions+ 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,6 +5,7 @@ import Baikai.Interactive (InteractiveProvider (InteractiveClaude, InteractiveCodex)) import Baikai.Kit ( AgentEntry (..),+ InstallOptions (..), KitCommand (..), KitCondition (..), KitConfig (..),@@ -13,6 +14,7 @@ KitItemKind (..), KitManifest (..), KitScope (..),+ KitVisibility (..), OutputFormat (..), OverwritePolicy (..), PlannedWrite (..),@@ -23,11 +25,16 @@ SkillEntry (..), UpstreamAvailability (..), WriteContent (..),+ addDisabledSkill, agentDirsForSession,+ checkVisibility, classify,+ codexSessionArgs, collectStatus, computeKitHash, conditionLabel,+ defaultInstallOptions,+ enableSkillsArgs, executePlanWith, findProjectRoot, installItem,@@ -41,8 +48,12 @@ projectRootByMarkers, pullKitRepo, readSidecar,+ readSkillEntries, reinstallPresent,+ relativeLinkTarget,+ removeDisabledSkill, renderConditions,+ renderStatusTable, renderUninstallReport, runKit, runKitCommand,@@ -64,6 +75,7 @@ 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)@@ -86,11 +98,14 @@ ( canonicalizePath, createDirectoryIfMissing, createDirectoryLink,+ createFileLink, doesDirectoryExist, doesFileExist, doesPathExist, getCurrentDirectory,+ getSymbolicLinkTarget, listDirectory,+ pathIsSymbolicLink, removeDirectoryRecursive, removeFile, renameFile,@@ -101,10 +116,12 @@ 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 =@@ -124,7 +141,11 @@ installFidelityTests, projectRootTests, commandTests,- jsonTests+ jsonTests,+ visibilityTests,+ codexVisibilityTests,+ visibilityStatusTests,+ visibilityRecoveryTests ] manifestTests :: TestTree@@ -227,10 +248,10 @@ 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+ result <- installItem testConfig "evil" UserScope defaultInstallOptions assertKitError "KitUnsafePath" isUnsafePath result assertFileMissing (takeDirectory home </> "escape.txt")- exitResult <- try @ExitCode (runKit testConfig (KitInstall (Just "evil") UserScope))+ exitResult <- try @ExitCode (runKit testConfig (KitInstall (Just "evil") UserScope defaultInstallOptions)) exitResult @?= Left (ExitFailure 1), testCase "uninstall refuses a traversal name" $ withPreparedKitHome $ \home _cache -> do@@ -268,13 +289,13 @@ withPreparedKitHome $ \home cache -> do plantSymlinkedSource home cache let claudeSkill = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"- result <- installItem testConfig "demo" UserScope+ 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+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions plantSymlinkedSource home cache rows <- collectStatus testConfig cache [(UserScope, "user")] let demoRows = filter ((== "demo") . view #name) rows@@ -315,7 +336,7 @@ @?= 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+ _ <- 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@@ -324,7 +345,7 @@ 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+ _ <- 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")]@@ -333,21 +354,21 @@ 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+ _ <- 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+ _ <- 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+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions BS.writeFile (userClaudeSkill home </> ".testkit-kit.json") legacySidecarJson rows <- collectStatus testConfig cache [(UserScope, "user")] conditions <- demoConditions rows "claude"@@ -355,7 +376,7 @@ 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+ _ <- 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@@ -366,8 +387,8 @@ -- 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- _ <- assertRight =<< installItem config "reviewer" UserScope+ _ <- 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@@ -404,7 +425,7 @@ 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+ _ <- assertRight =<< installItem config "demo" UserScope defaultInstallOptions assertFileExists (claudeSkill </> "SKILL.md") assertFileExists (codexSkill </> "SKILL.md") assertFileExists (claudeSkill </> ".testkit-kit.json")@@ -417,7 +438,7 @@ _ <- assertRight =<< uninstallItem config "demo" UserScope assertDirectoryMissing claudeSkill assertDirectoryMissing codexSkill- _ <- assertRight =<< installItem config "reviewer" UserScope+ _ <- assertRight =<< installItem config "reviewer" UserScope (InstallOptions Nothing True) assertFileExists claudeAgent assertFileExists codexAgent assertFileExists claudeAgentSidecar@@ -433,7 +454,7 @@ 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+ _ <- 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)),@@ -443,25 +464,25 @@ claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md" claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json" BS.writeFile (home </> ".codex") ""- result <- installItem testConfig "reviewer" UserScope+ 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]+ 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]+ 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]+ 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]+ 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+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions removeDirectoryRecursive codexSkill outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope let claudeOutcome = findOutcome InteractiveClaude outcomes@@ -493,16 +514,18 @@ assertKitError "KitManifestInvalid" isManifestInvalid invalid, testCase "installItem returns KitItemNotFound" $ withPreparedKitHome $ \_home _cache -> do- result <- installItem testConfig "nope" UserScope+ 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- flip finally (restoreHome oldHome) $ do+ setEnv "CODEX_HOME" (home </> ".codex")+ flip finally (restoreHome oldHome >> restoreCodexHome oldCodexHome) $ do exitResult <- try @ExitCode (runKit config (KitStatus HumanOutput)) exitResult @?= Right () report <- kitStatus config@@ -522,7 +545,7 @@ let claudeBase = home </> ".config" </> "testkit" </> "agents" claudeAgents = claudeBase </> ".claude" </> "agents" codexAgents = home </> ".codex" </> "agents"- _ <- assertRight =<< installItem testConfig "reviewer" UserScope+ _ <- assertRight =<< installItem testConfig "reviewer" UserScope (InstallOptions Nothing True) assertFileExists (claudeAgents </> "reviewer.md") assertFileExists (claudeAgents </> "reviewer" </> "guide.md") assertFileExists (codexAgents </> "reviewer.toml")@@ -565,7 +588,7 @@ withPreparedKitHome $ \home _cache -> do let claudeAgents = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "agents" createDirectoryIfMissing True (claudeAgents </> "reviewer.md")- result <- installItem testConfig "reviewer" UserScope+ result <- installItem testConfig "reviewer" UserScope (InstallOptions Nothing True) assertKitError "KitWriteFailed" isWriteFailed result assertFileMissing (home </> ".codex" </> "agents" </> "reviewer.toml"), testCase "unsupported manifest version is refused" $@@ -575,7 +598,7 @@ case loaded of Left (KitManifestVersionUnsupported _ 99) -> pure () other -> assertFailure ("expected KitManifestVersionUnsupported 99, got " <> show other)- installed <- installItem testConfig "demo" UserScope+ 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@@ -588,7 +611,7 @@ withPreparedKitHome $ \home cache -> do let claudeSkill = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo" upstream = cache </> "skills" </> "demo" </> "SKILL.md"- _ <- assertRight =<< installItem testConfig "demo" UserScope+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions BS.writeFile (claudeSkill </> "SKILL.md") "my edits" BS.writeFile upstream "new upstream" manifest <- assertRight =<< loadManifest cache@@ -613,7 +636,7 @@ withPreparedKitHome $ \home _cache -> do let claudeAgents = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "agents" cache = home </> ".cache" </> "testkit" </> "kit"- _ <- assertRight =<< installItem testConfig "reviewer" UserScope+ _ <- assertRight =<< installItem testConfig "reviewer" UserScope (InstallOptions Nothing True) removeFile (claudeAgents </> "reviewer.md") createDirectoryIfMissing True (claudeAgents </> "reviewer.md") manifest <- assertRight =<< loadManifest cache@@ -637,7 +660,11 @@ hash = h, installedAt = "2026-05-13T00:00:00Z", installedFiles = Nothing,- installedHash = Nothing+ installedHash = Nothing,+ visibility = Nothing,+ visibilitySource = Nothing,+ sharedLinks = Nothing,+ codexDisabledSkills = Nothing } mkSkillItem :: Text -> Maybe Text -> KitItem@@ -648,7 +675,8 @@ description = "x", version = mVersion, path = "skills/foo",- files = ["SKILL.md"]+ files = ["SKILL.md"],+ visibility = Nothing } mkAgentItem :: Text -> Maybe Text -> KitItem@@ -659,7 +687,8 @@ description = "x", version = mVersion, path = "agents/foo.md",- files = Nothing+ files = Nothing,+ visibility = Nothing } jsonTests :: TestTree@@ -678,8 +707,8 @@ testCase "kit-update document matches the golden" $ withPreparedKitHome $ \home cache -> do let config = testConfig & #projectRoot .~ pure (home </> "project")- _ <- assertRight =<< installItem config "demo" UserScope- _ <- assertRight =<< installItem config "reviewer" UserScope+ _ <- 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@@ -701,7 +730,7 @@ 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+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions (result, out) <- captureStdout (runKitCommand testConfig (KitStatus JsonOutput)) result @?= Right () document <- decodeDocument out@@ -773,10 +802,12 @@ 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- action home `finally` restoreHome oldHome+ setEnv "CODEX_HOME" (home </> ".codex")+ action home `finally` (restoreHome oldHome >> restoreCodexHome oldCodexHome) fixtureCache :: FilePath -> FilePath fixtureCache home = home </> ".cache" </> "testkit" </> "kit"@@ -800,11 +831,16 @@ 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)+ void (assertRight =<< installItem config n UserScope (InstallOptions Nothing True)) forM_ ["beta", "delta", "planner"] $ \n ->- void (assertRight =<< installItem config n ProjectScope)+ 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.@@ -850,6 +886,7 @@ <> n <> "\",\"files\":" <> files n+ <> (if n `elem` ["alpha", "gamma"] then ",\"visibility\":\"shared\"" else "") <> "}" agent n = "{\"name\":\""@@ -926,8 +963,8 @@ testGroup "Command" [ testCase "install parses with and without a name" $ do- parse ["install"] @?= Just (KitInstall Nothing UserScope)- parse ["install", "demo", "--project"] @?= Just (KitInstall (Just "demo") ProjectScope)+ 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@@ -936,9 +973,9 @@ 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)+ result <- runKitCommand testConfig (KitInstall Nothing UserScope defaultInstallOptions) result @?= Left KitItemNameRequired- exitResult <- try @ExitCode (runKit testConfig (KitInstall Nothing UserScope))+ 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@@ -947,23 +984,23 @@ 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)+ 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)+ result <- runKitCommand config (KitInstall Nothing UserScope defaultInstallOptions) result @?= Right () assertDirectoryMissing (userClaudeDemo home)- exitResult <- try @ExitCode (runKit config (KitInstall Nothing UserScope))+ 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)+ result <- runKitCommand config (KitInstall Nothing UserScope defaultInstallOptions) result @?= Left (KitItemNotFound "nope") ] where@@ -996,7 +1033,7 @@ let config = testConfig & #projectRoot .~ pure proj claudeSkill = proj </> ".testkit" </> "agents" </> ".claude" </> "skills" </> "demo" withCurrentDirectory (proj </> "src" </> "deep") $- void (assertRight =<< installItem config "demo" ProjectScope)+ void (assertRight =<< installItem config "demo" ProjectScope defaultInstallOptions) assertFileExists (claudeSkill </> "SKILL.md") assertFileExists (proj </> ".agents" </> "skills" </> "demo" </> "SKILL.md") assertDirectoryMissing (proj </> "src" </> "deep" </> ".testkit")@@ -1014,14 +1051,14 @@ assertDirectoryMissing claudeSkill let markerConfig = testConfig & #projectRoot .~ projectRootByMarkers [rootMarker] withCurrentDirectory (proj </> "src" </> "deep") $- void (assertRight =<< installItem markerConfig "demo" ProjectScope)+ 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+ _ <- assertRight =<< installItem testConfig "demo" ProjectScope defaultInstallOptions cwd <- getCurrentDirectory assertFileExists (cwd </> ".testkit" </> "agents" </> ".claude" </> "skills" </> "demo" </> "SKILL.md") assertDirectoryMissing (proj </> ".testkit")@@ -1064,6 +1101,7 @@ 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")@@ -1073,8 +1111,13 @@ 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+ 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@@ -1295,3 +1338,525 @@ 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"}]}