baikai-kit 0.1.0.4 → 0.4.0.0
raw patch · 24 files changed
Files
- CHANGELOG.md +2702/−0
- baikai-kit.cabal +70/−44
- src/Baikai/Kit.hs +8/−0
- src/Baikai/Kit/CodexConfig.hs +298/−0
- src/Baikai/Kit/Command.hs +221/−28
- src/Baikai/Kit/Config.hs +92/−7
- src/Baikai/Kit/Error.hs +122/−0
- src/Baikai/Kit/Install.hs +993/−414
- src/Baikai/Kit/Json.hs +147/−0
- src/Baikai/Kit/Link.hs +129/−0
- src/Baikai/Kit/Manifest.hs +60/−12
- src/Baikai/Kit/Path.hs +60/−3
- src/Baikai/Kit/Repo.hs +50/−28
- src/Baikai/Kit/Session.hs +31/−3
- src/Baikai/Kit/Sidecar.hs +72/−31
- src/Baikai/Kit/Status.hs +242/−101
- src/Baikai/Kit/Visibility.hs +30/−0
- test/Main.hs +1855/−491
- 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
@@ -0,0 +1,2702 @@+# Changelog++All notable changes to baikai are recorded here.++The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and+this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).++## [Unreleased]++### Changed++- Documentation: the 2026-10-02 model refresh found no new OpenAI or Anthropic+ models; the catalog is unchanged. `docs/user/models-and-providers.md` notes+ Anthropic's retirement of Claude Sonnet 4.5 on 2026-11-30 (deprecation of+ `anthropic_claude_sonnet_4_5` is planned in plan 89 for removal in 0.8.0.0),+ why the access-gated Claude Mythos models are not curated, and how GPT-6+ Astra's new ultrafast service tier is costed.++## [baikai-kit 0.4.0.0] - 2026-10-02++### Added++- Per-item `visibility` (`tool-only` by default, or `shared`) for manifest+ versions 1 and 2, with `kit install --shared`, `--tool-only`, and+ `--accept-shared-codex`. Shared Claude skills and agents use tracked links;+ tool-only Codex skills use tracked disabled entries in `config.toml`.+- `codexSessionArgs`, required in the `extraArgs` of a consumer's own Codex+ launches to re-enable its tool-only skills. This is the one launcher step+ beyond a dependency bump; the standard `kitConfig`/`kitCommandParser`/`runKit`+ integration otherwise compiles unchanged. Codex custom agents cannot be+ isolated and require explicit acceptance, or shared visibility.+- Requested and effective visibility in status (table and JSON), the+ `visibility-broken` condition, and a migration note for legacy shared Codex+ skills. Update repairs visibility even for items skipped for local edits;+ uninstall removes only owned links and config entries.++### Changed (breaking)++- `SkillEntry` and `AgentEntry` gain `visibility`; `SidecarMeta` gains+ `visibility`, `visibilitySource`, `sharedLinks`, and `codexDisabledSkills`;+ `newSidecarMeta` takes these values. `RemovalOutcome` gains `linksRemoved`+ and `configEntriesRemoved`; `StatusRow` gains `requestedVisibility` and+ `effectiveVisibility`; `KitConfig` gains `confirmSharedCodex` (default+ `Nothing` in `kitConfig`).+- `KitInstall` gains `InstallOptions`; `installItem` and `installFrom` take+ it as their last argument. Use `defaultInstallOptions` to follow the+ manifest. Explicit install flags persist across updates, while new+ manifest-driven installs follow updated defaults. Legacy placement is kept.+- JSON adds list `visibility`, status `requestedVisibility` and+ `effectiveVisibility`, and the new condition value. `formatVersion` stays+ 1 because all additions preserve existing keys and their meaning.++### Fixed++- Codex installs refuse destinations without this tool's sidecar instead of+ overwriting user or other-tool assets. Uninstall also preserves foreign+ Codex assets. Shared Claude names are checked before any provider write.+- Codex config edits preserve UTF-8 text, comments and permissions, refuse+ symlinks and invalid/conflicting config, and verify the semantic change+ before atomic rename. Reused user-owned disabled entries survive uninstall.+ Shared visibility refuses a user-owned entry that would keep the skill hidden.+- The source distribution ships the test suite's manifest fixtures and JSON+ goldens, so the test suite passes when run from the Hackage tarball. Since+ 0.3.0.0 it had failed there, because those files were missing.++## [baikai 0.7.2.0] - 2026-09-30++### Added++- Curated GPT-6.1 Sol on OpenAI Responses and Claude Sonnet 5.5 on Anthropic+ Messages (`openai_gpt_6_1_sol`, `anthropic_claude_sonnet_5_5`), with endpoint+ compatibility facts, standard prices, GPT-6.1 Sol's 272K-token context tier,+ and Sonnet 5.5's one-hour cache-write rate. Sonnet 5.5 rejects forced tool+ choice locally and has no fast mode. Both passed live text and function-tool+ acceptance on 2026-09-30 through `/v1/responses` and `/v1/messages`+ ([record](docs/validation/plan-85/2026-09-30-complete.json),+ [plan 85](docs/plans/85-prove-gpt-6-1-sol-and-claude-sonnet-5-5-live-compatibility.md)).+ The focused smoke runner gains the `sol61-*` and `sonnet55-*` cases.+- `Baikai.ResponseFormat.StructuredOutputSupport`+ (`NativeJsonSchema | NoStructuredOutput`), `declaredStructuredOutput :: Api ->+ StructuredOutputSupport`, and a `structuredOutput` field on `ApiProvider`+ (default `NoStructuredOutput` in `apiProviderWith`), so a caller can ask+ whether a transport enforces a schema without calling it. Every built-in+ provider declares `NativeJsonSchema`.++## [baikai-claude 0.7.1.0] - 2026-09-30++Requires `baikai >=0.7.2`.++### Added++- `claude -p` honours a `JsonSchema` response format (IR-11): it receives+ `--json-schema '<schema>'` and the response text is the tool's validated+ `structured_output`. A missing `structured_output` is a `DecodeFailure`; a+ `claude` too old for the flag yields an `InvalidRequest` error with the exit+ code rather than unconstrained text. `JsonObject`, `name` and `strict` are not+ forwarded; requests without a schema render the same argument vector as+ before. Both Claude providers declare `structuredOutput = NativeJsonSchema`.++### Fixed++- Anthropic adaptive `ThinkingHigh` now sends+ `output_config.effort: "high"` instead of omitting the field. Claude Opus 5.5+ defaults to `medium`, so it previously ran `ThinkingHigh` at medium effort;+ other adaptive models default to `high` and behave as before. Evidence for+ these calls records `effortText = "high"` and no longer carries+ `effort_omitted`, so strict evidence mode no longer refuses them.+ `Baikai.Evidence.EffortOmitted` stays exported, and older records still decode.++## [baikai-openai 0.7.1.0] - 2026-09-30++Requires `baikai >=0.7.2`.++### Added++- `codex exec` honours a `JsonSchema` response format (IR-11): the schema is+ written to a temporary file passed as `--output-schema <file>` and deleted+ however the call ends. A `codex` too old for the flag yields an+ `InvalidRequest` error with the exit code rather than unconstrained text.+ `JsonObject`, `name` and `strict` are not forwarded; requests without a schema+ render the same argument vector as before. All three OpenAI providers declare+ `structuredOutput = NativeJsonSchema`.+- `codexCliCommandWith`, which renders the `codex exec`+ vector with a given `--output-schema` file. `codexCliCommand` is unchanged+ and never renders the flag.++## [baikai 0.7.1.0] - 2026-09-23++### Added++- Curated GPT-6 Sol and Luna on OpenAI Responses and Claude Opus 5.5 on+ Anthropic Messages (`openai_gpt_6_sol`, `openai_gpt_6_luna`,+ `anthropic_claude_opus_5_5`), with endpoint compatibility and standard,+ long-context, cache-duration, and fast-mode prices where applicable. All+ three passed live acceptance on 2026-09-23. Sol and Luna dispatch to+ `OpenAIResponses`, so calling them requires the+ `Baikai.Provider.OpenAI.Responses.register` call that `baikai-openai 0.7.0.0`+ introduced.++## [baikai-kit 0.3.0.0] - 2026-09-23++Closes four improvement requests from the tools that ship `baikai-kit` as their+`kit` command: project scope resolves from a configurable root (IR-8),+`kit status` reports local edits separately from upstream drift (IR-7),+`kit install` without a name asks a tool-supplied chooser (IR-6), and `list`,+`status` and `update` print versioned JSON (IR-9). A consumer raising its bound+builds `KitConfig` with `kitConfig`, passes it to `kitCommandParser`, and+matches on `StatusRow.conditions`; each break is at a call site the compiler+names.++### Added++- `baikai-kit`: `KitConfig.projectRoot :: IO FilePath` says where project scope+ lives. Install, status, update, uninstall and `agentDirsForSession` all derive+ project-scope paths from it, so they agree whichever subdirectory a command+ runs from. `kitConfig` builds a configuration with every optional field at its+ default (project scope is the current directory, as before);+ `projectRootByMarkers [".git", ".mytool"]` is a ready-made resolver that walks+ up to the nearest marker and falls back to the current directory, and+ `findProjectRoot` is the underlying walk. Resolves IR-8.++- `baikai-kit`: `kit status` reports local edits. It runs the same+ installed-file check `kit update` uses to skip an item, and shows+ `modified` for an edited copy and `edits-unknown` for one whose sidecar+ predates the installed-file hash. The check is exported as+ `checkLocalEdits`, returning `LocalEdits` (`Unedited`, `Edited`,+ `EditsUnknown`). Resolves IR-7.++- `baikai-kit`: `kit install` with no name asks a chooser the tool supplies in+ the new `KitConfig.chooseItem :: Maybe (KitManifest -> IO (Maybe Text))`+ field. The engine refreshes the kit, passes the whole manifest, and installs+ what the chooser returns; a cancelled choice prints+ `No item chosen; nothing installed.` and exits 0. With no chooser (the+ `kitConfig` default) the command fails with the new `KitItemNameRequired`+ error, which tells the user to pass `NAME`. The engine ships no picker.+ `KitCommand` derives `Eq`, and `kit install --help` names the tool's+ `.<tool>/agents` directory. Resolves IR-6.++- `baikai-kit`: `kit list`, `kit status` and `kit update` accept `--json` and+ print exactly one versioned JSON document on stdout+ (`{"formatVersion": 1, "document": "kit-list" | "kit-status" | "kit-update", …}`);+ warnings and the first-clone notice go to stderr, and a failed command writes+ nothing to stdout. The shapes are written by explicit encoders —+ `Baikai.Kit.Json.listDocument`, `statusDocument`, `updateDocument`, and+ `kitJsonFormatVersion` — so library callers get the same values, and they are+ pinned by golden tests. `Baikai.Kit.Command.OutputFormat` selects the mode,+ and `Baikai.Kit.Status.InstalledCopy` / `installedCopies` report where each+ item is installed. Resolves IR-9.++### Changed++- `baikai-kit`: `KitConfig` gains the strict field `projectRoot`, so a record+ literal must set it; build the configuration with+ `kitConfig toolName repoUrl providers` instead and override fields with record+ update syntax. `KitConfig`'s `Show` instance is now hand-written and prints+ `<IO FilePath>` for the resolver. __Breaking__.++- `baikai-kit`: `kit status` conditions compose, and `dirty` is renamed+ `changed-upstream` (it meant the upstream sources changed without a version+ bump, not local edits); `dirty+outdated` now reads+ `outdated+changed-upstream`. `StatusRow.state :: KitState` is replaced by+ `StatusRow.conditions :: [KitCondition]` (sorted; empty means up to date),+ `renderState` by `conditionLabel` and `renderConditions`, and `classify`+ returns `[KitCondition]`. `KitUpToDate`, `KitDirty` and `KitDirtyOutdated`+ are gone; match on the list instead. __Breaking__.++- `baikai-kit`: `KitInstall` takes `Maybe Text` (`Nothing` asks the chooser),+ `kitCommandParser` takes the `KitConfig` (migration: `kitCommandParser`+ becomes `kitCommandParser myKitConfig`), `KitConfig` gains the `chooseItem`+ field (set by `kitConfig`), and `KitError` gains `KitItemNameRequired`.+ __Breaking__.++- `baikai-kit`: `KitList`, `KitStatus` and `KitUpdate` gain a trailing+ `OutputFormat` field (`HumanOutput` for the previous behaviour).+ __Breaking__.++## [baikai-effectful 0.4.0.2] - 2026-09-15++### Changed (dependencies)++- Requires `effectful-core ^>=2.7` (was `^>=2.6`). No API change: none of the+ 2.7 breaking APIs (`LocalEnv`'s second type parameter, `SharedSuffix`,+ `KnownEffects`, the ticked strict modules) are used.++## [baikai 0.7.0.0] - 2026-09-08++### Added++- `BaikaiError.refusalCategory` preserves an Anthropic refusal's+ provider category. JSON adds `refusal_category`; older errors still decode.+ Evidence schema 2.5 records the addition without changing digest inputs.+ __Breaking__ to construct a `BaikaiError` from its full field list.++- `Speed`, `Options.speed`, catalog-owned fast rates and+ `computeCostAtSpeed`. Anthropic gates fast mode by model capability, adds the+ beta header and records unsupported drops. Terminal pricing uses observed+ speed, including cache duration; unreported speed is an explicit estimate.+ Older `Model` JSON defaults the new fields safely. __Breaking__: public+ records and sum types gain fields and constructors.++- API usage now records observed service tiers, inference speed and server-tool+ use in optional billing facts covered by evidence schema 2.2. Missing service+ information and uncurated products produce explicit standard-rate estimates.+ `computeCostForService` separates requested and observed service, while+ `computeCostAtRates` prices a resolved rate set once for future speed policies.+ Empty billing facts preserve legacy availability JSON; a CLI-reported zero+ cost retains its reported-total source. __Breaking__: the public vocabulary+ and records gain members.++- Failed trace terminals now retain partial response token counts, cost basis,+ usage availability and USD totals. Synthetic aborts leave unreported billing+ absent; legacy failed trace JSON still decodes. __Breaking__: `TraceEvent`'s+ `CallFailed` gains fields. See `baikai-trace-otel 0.4.0.1` for the export.++- Successful trace terminals and call-log records carry optional cost basis and+ usage availability; call logs also carry cache-write counts. Old JSON decodes+ with absent metadata and empty additive-zero bases stay omitted from traces.+ __Breaking__: `CallFinished` and the call-log record gain fields.++- Optional `Usage.availability` and shared inclusive/exclusive billing+ normalization, in the new `Baikai.Usage.Normalize`. OpenAI Chat/Responses and+ Claude preserve missing cache counters as explicit estimation reasons,+ distinguish reported zeroes, and merge cumulative usage without+ double-counting. Schema 2.2 commits provider availability while preserving+ legacy usage digests. __Breaking__: `Usage` gains a field.++- Optional `Model.pricingPolicy`, exact whole-request context tiers,+ and an explicit cache-duration rate resolver. Generated Astra pricing changes+ above 272000 input tokens; Fable exposes its one-hour write price. `Cost.basis`+ preserves calculation sources and estimation reasons when summed. Evidence+ schema 2.2 serializes the local basis without including local pricing metadata+ in provider commitments. __Breaking__: `Model` and `Cost` gain fields.++- Separate `OpenAIResponses` dispatch and compatibility types, and+ optional provider/model-scoped `ThinkingContent.replayState` with opaque+ diagnostic output and backward-compatible JSON decoding. Evidence schema 2.2+ includes replay state and optional billing facts in commitments while preserving+ legacy encodings when those fields are absent. __Breaking__ for a `case` over+ `Api` that is exhaustive without a wildcard.++- `Baikai.Evidence.ThinkingTranslation` gains `displayText` and+ `ThinkingAdjustment` gains `ThinkingSummaryUnavailable`, so a transport can+ record the thinking display setting it asked for and diagnose a successful+ response whose thinking blocks carry no readable summary. `Baikai.Compat`+ gains `supportsForcedToolChoice`; legacy JSON defaults it to True.+ __Breaking__ for an exhaustive `case` over `ThinkingAdjustment`.++- GPT-6 Astra and Claude Fable 5.1 catalog bindings, with verified+ pricing, token limits, and Anthropic thinking/sampling compatibility.++- Repository `update-models` skill for verifying provider releases and refreshing+ the curated JSON and generated Haskell catalog.++### Fixed++- Preserve OpenAI endpoint capability facts through catalog refreshes.++- Chat and Claude reject provider-scoped reasoning replay they cannot encode.++- Widened the `http-client-tls` bound to admit 0.4 (carried forward from the+ tagged but never-published 0.6.0.1).++## [baikai-claude 0.7.0.0] - 2026-09-08++### Changed++- Refusal messages include the reported category and explanation,+ retaining the original message when neither exists. Classification remains+ non-retryable `ContentFiltered`. Server-side fallbacks remain deliberately+ unsupported, as recorded in ADR 0005.++- Adaptive reasoning requests explicitly ask for summarized+ thinking. Evidence schema 2.4 records the display setting and diagnoses+ successful responses whose thinking blocks contain no readable summary.+ Budget and absent-thinking request shapes, signed empty blocks, redacted+ content and multi-turn replay are preserved.++- Fast mode is gated by the generated model capability: it adds the Anthropic+ beta header for a model that advertises it and records an evidence adjustment+ for one that does not.++### Fixed++- Price Fable cache writes using the TTL in the shaped request,+ including compatibility downgrades. Missing write-duration context is explicit+ in the cost basis.++- Reject forced tool choices locally on Fable 5.1, using the+ generated `supportsForcedToolChoice` capability. Automatic tool rounds retain+ signed empty/visible thinking, redacted blocks and prior-message order.++- Widened the `http-client-tls` bound to admit 0.4 (carried forward from the+ tagged but never-published 0.6.0.1).++### Changed (dependencies)++- Requires `baikai ^>=0.7.0`.++## [baikai-openai 0.7.0.0] - 2026-09-08++### Added++- Explicit `Baikai.Provider.OpenAI.Responses` registration and+ stream/complete provider with stateless reasoning replay, function tool turns,+ structured output and bounded worker cleanup, across the new+ `Baikai.Provider.OpenAI.Responses.{Request,Stream,Assembler}` modules. Astra+ now selects this provider through a per-model catalog override; callers must+ register it explicitly. Cache writes, billing availability and context pricing+ are integrated.++- `Baikai.Provider.OpenAI.Internal.Usage`, the shared usage mapping both the+ Chat Completions and Responses transports read.++### Fixed++- Reject tools locally for models whose Chat Completions endpoint+ disallows them, including GPT-6 Astra. Respect generated effort policies and+ sampling restrictions, with matching translation evidence and strict refusal.++- Validate Responses terminals and enforce the stream contracts.++- Widened the `http-client-tls` bound to admit 0.4 (carried forward from the+ tagged but never-published 0.6.0.1).++### Changed (dependencies)++- Requires `baikai ^>=0.7.0`.++## [baikai-trace-otel 0.4.0.1] - 2026-09-08++### Added++- Successful and failed spans export `baikai.cost.basis` and+ `baikai.usage.availability` as canonically encoded JSON. A failed span now+ also carries the input/output token counts and USD total that+ `baikai 0.7.0.0` retains on `CallFailed`, alongside its error status.++### Changed (dependencies)++- Requires `baikai ^>=0.7.0`, and now depends on `aeson ^>=2.2` to encode the+ two new attributes.++## [baikai-effectful 0.4.0.1] - 2026-09-08++### Changed (dependencies)++- Requires `baikai ^>=0.7.0`. No API change.++## [baikai-kit 0.2.0.1] - 2026-09-08++### Changed (dependencies)++- Requires `baikai ^>=0.7.0`. No API change.++## [baikai-agent 0.2.0.1] - 2026-09-08++### Changed (dependencies)++- Requires `baikai ^>=0.7.0`, `baikai-claude ^>=0.7` and+ `baikai-openai ^>=0.7`. No API change.++## [baikai 0.6.0.1] - 2026-08-30++### Fixed++- widened the `http-client-tls` bound to admit 0.4. The 0.4 API retains the+ manager functions this package uses and belongs to the same TLS 2.x / Crypton+ 1.1 dependency cohort as baikai 0.6; the old `^>=0.3` cap made baikai 0.6+ impossible to solve in applications that require Crypton 1.1.++## [baikai-claude 0.6.0.1] - 2026-08-30++### Fixed++- widened the `http-client-tls` bound to admit 0.4, allowing applications that+ require Crypton 1.1 to solve the dependency set.++## [baikai-openai 0.6.0.1] - 2026-08-30++### Fixed++- widened the `http-client-tls` bound to admit 0.4, allowing applications that+ require Crypton 1.1 to solve the dependency set.++## [baikai 0.6.0.0] - 2026-08-28++### Added++- `baikai`: `Baikai.ThinkingLevel.parseThinkingLevel :: Text -> Maybe+ ThinkingLevel` and `Baikai.Evidence.parseEvidenceStrength :: Text -> Maybe+ EvidenceStrength`, each beside its renderer. Three hand-copied tables — the+ evidence schema's level parser, `baikai-agent`'s KDL `effort` decoder, and its+ `--require-evidence` parser — now read them instead, so a level or strength+ added later cannot be added in one place and missed in three. (REV-2 G.6.)++- `baikai`: `Baikai.Agent.AgentRunResult` exports its selectors (`provider`,+ `exitCode`, `stdout`, `stderr`, `duration`). It exported neither them nor its+ constructor, so a consumer without generic-lens could not read a run's exit+ code at all. (REV-2 G.6.)++- `baikai`: `Baikai.Api.normaliseApi :: Api -> Api`, which collapses a `Custom`+ tag that spells a built-in API onto that constructor. The registry applies it+ to the key it stores and to the tag it is asked for, so a handler registered+ under `Custom "anthropic-messages"` answers a model tagged `AnthropicMessages`+ and the reverse; the two used to be separate entries and dispatch depended on+ which spelling the model happened to carry. Derived `Eq`/`Ord` on `Api` are+ deliberately unchanged: altering them would silently rearrange every+ `Map Api` a consumer holds. (REV-2 G.4.)++- `baikai`: `Baikai.Header`, a new module exporting `HeaderName` with+ `headerName` and `renderHeaderName`. See the `headers` retype under Changed.++- `baikai`: `Baikai.Error.ErrorCategory` gains `ContentFiltered` (wire tag+ `content_filtered`, never retryable) with the smart constructor+ `contentFiltered`. OpenAI's `finish_reason: "content_filter"` and Anthropic's+ `refusal` stop now carry it. Both used to be `OtherError`, so the only way to+ tell a filtered response from any other non-retryable failure was to match on+ the message text. __Breaking__ for a consumer whose `case` over+ `ErrorCategory` is exhaustive without a wildcard. (REV-1 1.7 residual.)++- `baikai` (breaking to construct, not to read): every record that can still+ grow a field is now built from an exported base value and refined by record+ update, and its constructor is no longer exported —+ `Baikai.Provider.Registry.ApiProvider` (`apiProvider` /`apiProviderWith`),+ `Baikai.Evidence.ModelCallEvidence` (`baseEvidence`),+ `Baikai.Evidence.EvidenceRequest` (`evidenceRequest`), `Baikai.Tool.Tool`+ (`mkTool`, with `emptyTool` kept for fixtures),+ `Baikai.Embedding.EmbeddingModel` (`emptyEmbeddingModel`),+ `Baikai.Cost.Log.CallLogConfig` (`callLogConfig`),+ `baikai-trace-otel`'s `OtelSinkOptions` (`defaultOtelSinkOptions`), and+ `baikai-agent`'s `AgentCliOptions` (`agentCliOptions`), `AgentCliRun`+ (`agentCliRun`), `AgentJob` (`agentJob`) and `AgentConfigPaths`+ (`emptyAgentConfigPaths`). Selectors, record update, `OverloadedRecordDot`+ reads and generic-lens labels all keep working; only construction from the+ constructor stops. Adding `describeThinking` to `ApiProvider` in 0.5.0.0 broke+ every third-party registration site, and `strengthCeiling` would have broken+ them again; from this release such an addition is a minor bump. (REV-2 G.1.)++- `baikai`: `Baikai.Provider.apiProvider`, which builds an `ApiProvider` from an+ `Api` tag and a streaming producer, deriving `complete` with+ `streamingComplete`; and `Baikai.Provider.Registry.apiProviderWith`, which+ takes the completer explicitly. Both default `describeThinking` to+ "nothing requested, nothing translated" and `strengthCeiling` to+ `EvidenceRequestedOnly`, matching `declaredStrength (Custom _)`.++- `baikai`: `Baikai.Tool.mkTool` — a tool from its name, description and JSON+ Schema. A tool built from `emptyTool` and sent unchanged reaches the wire with+ `input_schema: null`; `mkTool` has no such shape.++- `baikai`: `Baikai.Agent.AgentOutputFormat` (`TextFormat`, `JsonFormat`) with+ `renderAgentOutputFormat` and `parseAgentOutputFormat`, and+ `AgentRunRequest.outputFormat`, defaulting to `TextFormat`. `baikai-claude`+ renders `--output-format json` and `baikai-openai` renders `--json`, both+ right after the effort flags; `baikai-agent` reads it from+ `jobs.<name>.output-format`. This is the one setting an evidence record needs+ in order to observe a run's session, model and usage, and asking for it used+ to require the `provider-args` channel that an operator ceiling closes by+ default — an operator should not have to open a privileged channel to get a+ record. (REV-2 F.14.)++- `baikai`: `Baikai.Agent.AgentCeiling` gains three fields and the module gains+ the vocabulary they need. `allowedTools :: [Text]` names tool grants the+ operator permits beyond the ones `toolGrantsImpliedBy` (also new) says a+ capability implies on its own; `maxTimeout :: Maybe NominalDiffTime` and+ `maxOutputLimit :: Maybe Int` bound what any job may request, the second+ defaulting to the new `defaultMaxOutputLimit` (67108864, sixty-four+ mebibytes). `Baikai.Agent.ceilingViolations` is `applyAgentCeiling`'s violation+ list on its own, so a caller can concatenate it with violations of its own.+ (REV-2 F.3.)++- `baikai`: `Baikai.Content.toolArgumentsFromText` and+ `Baikai.Content.isCutOffToolCall`. The first is the single rule that turns a+ tool call's accumulated argument text into its `arguments` value — empty text+ is an empty object, non-empty text that does not decode is kept verbatim as a+ `String` — and both provider assemblers and core's stream-recovery path now+ use it, so the second means the same thing at every layer.++- `baikai`: new exposed module `Baikai.Provider.Internal.StreamWorker` — the+ bounded hand-off both HTTP providers now use between their SSE worker thread+ and the consumer draining the stream. `FrameQueue` is a 64-slot `TBQueue` plus+ a closed flag; `forkFrameWorker` closes the queue however the body ends, and+ `withFrameWorker` runs the consumer under `Stream.bracketIO` so the worker is+ killed when the stream stops. The module is exposed like+ `Baikai.Provider.Cli.Internal`, outside the PVP promise. See+ [docs/adr/0010](docs/adr/0010-a-stream-consumer-that-stops-owns-cancelling-the-producer.md).++- `baikai`: every Anthropic model in the generated catalog now carries an+ explicit `CompatAnthropicMessages` record stating the two request-shaping+ facts of its generation: `AnthropicMessagesCompat.thinkingStyle` (which+ extended-thinking wire shape it accepts) and the new+ `AnthropicMessagesCompat.supportsSamplingParameters` (whether it accepts+ `temperature`, `top_p` and `top_k`). Both are sourced from+ `baikai/data/models/anthropic.json`, which the fetcher writes from its+ curated `anthropicInclude` table, and `baikai-gen-models` now refuses an+ `anthropic-messages` entry that reaches it without a `compat` block rather+ than falling back to host auto-detection, which cannot know a generation.+ This is what fixes `claude-sonnet-5`, whose thinking requests were shaped by+ a prefix table that did not know the id. See+ [docs/adr/0009](docs/adr/0009-provider-capability-facts-live-in-the-generated-catalog-record.md).++- `baikai`: two new `Baikai.Evidence.ThinkingAdjustment` constructors,+ `SamplingDroppedUnsupportedModel` and `SamplingDroppedUnsupportedApi`, encoding as+ `{"kind":"sampling_dropped_unsupported_model","fields":["temperature","top_p"]}` and+ `{"kind":"sampling_dropped_unsupported_api","fields":["seed"]}`. They record sampling+ parameters removed because the model generation rejects them, or because the API has no+ such field on any generation. Both carry a `fields` array and no `requested` level, so+ they can appear on a call whose thinking mode is `absent`.++- `baikai`: `Baikai.Evidence.weakensThinking`, which says whether an adjustment weakens the+ thinking the caller asked for. Strict evidence mode filters through it, so a dropped+ sampling parameter is recorded without refusing the call — the documented contract is+ refusing a call that would weaken the requested *thinking level*.++- `baikai`: new exposed module `Baikai.Url` — the one place baikai turns a URL+ into a host name. `parseUrl` yields a `UrlParts` record with the scheme, host,+ port and path, plus flags saying whether userinfo, a query string or a+ fragment were present; it never holds their text, so the value cannot carry a+ secret into a log line. Alongside it: `urlHost`, `hostMatchesSuffix` (moved+ from `Baikai.Compat`, which now re-exports both), `renderEndpoint`,+ `stripApiVersion`, and `baseUrlProblem`, which says why a URL is unusable as a+ `Model.baseUrl` and what to do instead. See+ [docs/adr/0008](docs/adr/0008-one-url-host-parser-and-every-consumer-uses-it.md).++- `baikai`: new exposed module `Baikai.Provider.Transport.Classify` — the one+ rule every HTTP provider uses to classify a transport failure, exporting+ `classifyTransportException` plus the per-type functions it composes. The rule+ is *where* the failure happened, not what type it is: anything that breaks or+ ends the connection after the request went out is `TransientError`, anything+ that says the request or the configuration is wrong is not retryable, and a+ programming error stays `OtherError`. It understands all three shapes+ `http-client` can deliver — an `HttpException` of any constructor, a raw socket+ `IOException`, and a raw or wrapped `TLSException` — because the manager wraps+ the connect phase but not the body reader. Core gains direct `build-depends` on+ `http-types` and `tls`, both already in its install plan. Written for+ third-party `Custom` providers built on `http-client` as much as for baikai's+ own two. See+ [docs/adr/0011](docs/adr/0011-core-owns-transport-failure-classification.md).++- `baikai`: `Baikai.Error.parseHttpDate` and `Baikai.Error.retryAfterSecondsAt`.+ The first parses an HTTP-date in the IMF-fixdate form servers must send plus+ the two obsolete forms a recipient must accept; the second converts a+ `Retry-After` header in either of its forms to seconds against a reference+ instant, clamping a date already in the past to `0`.+ `parseRetryAfterSeconds` keeps its integer-only contract, now a deliberate+ division of labour rather than a limitation.++- `baikai`: new exposed module `Baikai.Http` — `canonicalBaseUrl`,+ `getClientEnvCached` and `cachedClientEnvCount`, the process-global+ `ClientEnv` cache that both HTTP provider packages now share instead of each+ keeping its own. Core gains direct `build-depends` on `servant-client`,+ `http-client` and `http-client-tls`, which were already in its install plan+ through the `openai` SDK.++- `baikai`: `Baikai.Evidence.ThinkingModeNotTranslated`, encoded as+ `"not_translated"`, and `Baikai.Evidence.untranslatedThinking`; and+ `Baikai.Evidence.Build.requestedTranslation`. A path where no adapter ran to+ translate the caller's level now records the level and says the translation is+ unknown, instead of saying nothing was asked. (REV-2 D.2.)++- `baikai`: `Baikai.Evidence.Build.missingEvidenceError`,+ `Baikai.Evidence.Build.strictnessOf` (moved here from `Baikai.Trace`, where it+ was private), `Baikai.Stream.requireEvidenceOnTerminal` and+ `Baikai.Provider.Registry.requireEvidenceOnResponse`. (REV-2 D.3.)++- `baikai`: `Baikai.Evidence.usageEnvelope`, and+ `Baikai.Evidence.Build.endpointIdentityAt`, `prepareEvidenceAt` and+ `minimalEvidenceAt`, which take the base URL the adapter actually resolved.+ The three unsuffixed functions remain and pass the model's own field.+ (REV-2 D.8, D.11.)++- `baikai`: `Baikai.Evidence.deriveStrength`, the single rule that turns an+ observed model, a provider request id and a response id into an+ `EvidenceStrength`. (REV-2 D.10.)++### Changed++- `baikai`: catalog refresh. `claude-opus-5` joins the curated Anthropic include+ set (adaptive thinking, sampling parameters rejected — the facts+ `docs/plans/60-make-anthropic-thinking-style-and-sampling-support-catalog-driven.md`+ said whoever curated it in would have to state), and the `gpt-5.6` family+ picks up its price cut: `gpt-5.6` and `gpt-5.6-sol` to $4.00/$20.00,+ `gpt-5.6-terra` to $2.00/$12.00, `gpt-5.6-luna` to $0.20/$1.20 per Mtok, cache+ rates in step. `Baikai.Models.Generated` gains `anthropic_claude_opus_5` and+ now carries 36 enabled models. No OpenAI id was added: the `gpt-5.6` family is+ still the newest one models.dev reports that speaks+ `openai-chat-completions`.++- `baikai` (breaking): `ResponseFormat`'s `JsonSchema` carries a+ `JsonSchemaFormat` record — `name`, `schema`, `strict`, exported+ selector-only with the base `jsonSchemaFormat name schema` — instead of+ holding the three fields directly. As fields of a sum they were partial+ selectors: `name f` on a `JsonObject` crashed at runtime rather than failing to+ typecheck, which contradicted the module's own documentation.+ `-Wno-partial-fields` is dropped from the module. The JSON encoding is+ deliberately unchanged (`{"tag":"JsonSchema","name":…,"schema":…,"strict":…}`)+ and is now pinned by a test, because `Options` derives `ToJSON` through it and+ at least one consumer keys a cache on the result. (REV-2 G.2.)++- `baikai`: `Baikai.Context.appendToolResult` returns its input context+ unchanged, and runs no dispatcher, when the response is error-shaped. A failed+ call has no assistant turn worth replaying and no tool calls to answer;+ appending its empty message put a turn into the transcript the model never+ took. `runToolLoop` has always stopped on such a response — the documented+ direct round trip in `docs/user/tools.md` reaches `appendToolResult` instead,+ and now behaves the same way. Its Haddock also stops claiming multi-call+ concurrency lives in the dispatcher: the calls are traversed in order.+ (REV-2 G.7.)++- Release metadata (REV-2 G.8): every publishable package now declares+ `tested-with: GHC ==9.12.4` and ships its `CHANGELOG.md` (a symlink to the+ root one, as `baikai` already did) via `extra-doc-files`, so Hackage shows a+ changelog and a tested compiler for all seven. `baikai-claude` and+ `baikai-openai` describe what they actually contain — four surfaces each, not+ "wraps package X" — and `baikai-trace-otel`'s `streamly-core` bound is+ `>=0.3 && <0.5`, matching every other package in the workspace rather than+ excluding the 0.4 series the others accept.++- `baikai` (breaking): `Options.headers` and `Model.headers` are keyed on+ `Baikai.Header.HeaderName` — a newtype over a case-insensitive `CI Text` that+ keeps the original spelling — instead of `Text`. A header name is+ case-insensitive on the wire, so a `Map Text Text` holding both+ `Authorization` and `authorization` sent whichever the assembling fold reached+ last; the map now holds one entry per header and the last write wins, as a+ caller writing two spellings would expect. `HeaderName` has an `IsString`+ instance, so `Map.singleton "x-test" "1"` and `#headers` updates keep+ compiling; the spelling given is what goes out on the wire and into JSON.+ (REV-2 G.5.)++- `baikai` (breaking): `Options.stopSequences` is `[Text]`, where empty means+ "send nothing", instead of `Maybe (Vector Text)` — `Nothing` and `Just []`+ were indistinguishable on the wire and only one of them could be right. Plan+ 43's rule is lists for caller-side configuration and `Vector` for+ provider-bound sequences; this was the one field breaking it. `Options.seed`+ is `Maybe Int` rather than `Maybe Integer`: a seed is a machine integer at+ every provider that accepts one, and it now sits beside+ `timeoutMs :: Maybe Int`. (REV-2 G.5, R14.)++- `baikai` (breaking): `StopReason.Aborted` is removed. Nothing produced it —+ timeouts are `ErrorReason`/`TransientError`, and a consumer abort is recorded+ as evidence `CallAborted` — while `responseError`, `eventsFor` and+ `runToolLoop` all treated it as a *success*, so a value that reached any of+ them would have been silently mishandled. Since 0.6.0.0 a stream consumer that+ stops cancels the producer, so no consumer is left to receive such a terminal+ either. (REV-2 B.6.)++- `baikai`: dispatching a model whose `api` is still `emptyModel`'s+ `Custom ""` says so — `No provider registered for API: <blank Custom tag —+ emptyModel.api was never set>` — where the message used to end after the+ colon. `emptyModel`'s Haddock says the same thing. (REV-2 G.4.)++- `baikai`: `withTrace` and `withTraceStream` wait at most one second for the+ trace sink after writing the shutdown sentinel. On expiry the worker is+ abandoned — not killed, which would abort the sink's fold mid-step and lose+ its end-of-stream action — the call proceeds, and one stderr line reports+ `the trace sink did not confirm delivery within 1000 ms; its worker was+ abandoned, and events already queued may still be delivered later`. A sink+ that blocked forever used to hold the call forever and swallow the first+ attempt to cancel it. A caller under `EvidenceRequired` whose sink did not+ confirm delivery gets a failed call, through the same path a throwing sink+ takes; `Baikai.Evidence.Build.sinkFailureError` now says "its record was not+ confirmed written" rather than "not written", which is the honest claim for+ an abandoned worker whose events are still queued. The synthetic terminal a+ consumer's abort produces is delivered from a garbage-collection hook and is+ not guaranteed before process exit; that was always true and is now stated in+ `docs/user/model-call-evidence.md`, `docs/capabilities/call-tracing.md` and+ the `Baikai.Trace` module documentation, with the pattern for callers who need+ the record. See+ [docs/adr/0015](docs/adr/0015-trace-cleanup-is-bounded-and-abort-cleanup-is-gc-eventual.md).+ (REV-2 D.5, Theme 7.3.)++- `baikai`: `Baikai.Trace.Sink.multiSink` runs each member on its own drain+ thread behind its own unbounded channel, instead of folding `Fold.tee` across+ the list. `Fold.tee` runs one member then the other and lets either's+ exception escape, so a single throwing member stopped delivery to every+ sibling for the rest of the call and skipped their end-of-stream actions — an+ OpenTelemetry span paired with an unwritable file sink was opened and never+ ended, and nothing was exported. The step never blocks; the final action sends+ every member the sentinel, waits for every member, and reports one aggregate+ failure naming each failed member by zero-based index+ (`1 of 2 member sinks failed: member 0: …`). (REV-2 D.6.)++- `baikai`: `AgentSafety.allowedTools` is documented as the __grant__ it is.+ On Claude Code it renders `--allowedTools`, whose help reads "list of tool+ names to allow": it pre-approves tools the permission mode would otherwise+ raise a request for, and in an unattended run a request nobody answers is+ denied. The old Haddock called it "optional narrowing of the provider's tool+ set", which was the opposite, and `applyAgentCeiling` never looked at it. It+ is now bounded: a grant passes when the maximum capability implies it+ (`read-only` implies `Read`, `Glob`, `Grep`, `NotebookRead`, `TodoWrite`;+ `edit-workspace` adds `Edit`, `MultiEdit`, `Write`, `NotebookEdit`;+ `full-access` implies every grant) or when the operator named it in+ `policy.allowed-tools`. Matching is exact, so `Bash(git *)` is not `Bash`.+ A repository job that grants itself `Bash` under `edit-workspace` — which+ passed unexamined before — is now refused with exit 77 before any process is+ created. (REV-2 F.3.)++- `baikai` (breaking): `Baikai.Agent.CeilingViolation` gains five constructors:+ `ToolGrantForbidden`, `TimeoutExceeded`, `OutputLimitExceeded`,+ `RepositoryScopeForbidden` and `WorkingDirOutsideRepository`. A `case` over+ the type that was exhaustive is no longer.++- `baikai` (behaviour): the default ceiling has a finite `maxOutputLimit`, so+ `applyAgentCeiling defaultAgentCeiling` now refuses a request whose+ `outputLimit` is `Nothing` — capture without bound is exactly what the+ maximum exists to refuse. Jobs resolved through `baikai-agent` are unaffected:+ that layer's own default supplies a finite limit, and only an explicit+ `output-limit "unlimited"` reaches the ceiling as `Nothing`.++- `baikai`: a tool call cut off by the output cap is no longer executed.+ `runToolLoop` stops with the response and its tool calls intact when any call+ is cut off, and `appendToolResult` appends a `ToolResultMessage` with+ `isError = True` explaining why instead of calling the dispatcher. Previously+ both assemblers replaced truncated arguments with `{}` and a tool loop+ happily ran the call with no arguments at all. (REV-2 B.2.)++- `baikai`: `Baikai.Model.anthropicMessagesCompatFor` no longer overlays a+ thinking style guessed from the model id onto a model whose `compat` is+ `CompatNone`. `CompatNone` now means host auto-detection alone — the budget+ thinking shape, sampling parameters supported. Every catalog model carries an+ explicit record, so this changes nothing for them; a **hand-rolled** model+ naming an adaptive-era id (`claude-sonnet-5`, `claude-opus-4-7`,+ `claude-opus-4-8`, `claude-fable-5`) must now carry+ `CompatAnthropicMessages (defaultAnthropicMessagesCompat {thinkingStyle = AnthropicThinkingAdaptive, supportsSamplingParameters = False})`+ or start from the catalog value.++- `baikai`: `Baikai.Evidence.evidenceSchemaVersion` is now+ `baikai.model-call-evidence/1.1`. A minor bump: the two sampling adjustment kinds are a+ compatible addition, and no previously recorded digest changes.++- `baikai`: HTTP 413 classifies as `ContextOverflow` rather than `OtherError`,+ from the status alone and whatever the body says. 413 *is* the size-limit+ status and the caller's remedy — shrink the input — is the same either way;+ making the category depend on body wording would recreate for 413 the+ inconsistency this release fixes for connection resets. (REV-2 A.7.)++- `baikai`, `baikai-claude`, `baikai-openai`: an HTTP-date `Retry-After` is+ converted to seconds instead of ignored. Both transports use the response's own+ `Date` header as the reference instant, falling back to the local clock, so a+ CDN-fronted `429` — the common case for a date-valued `Retry-After` — now+ carries a hint rather than leaving the caller to guess. (REV-2 A.9.)++- `baikai`: **breaking.** `Baikai.Embedding.EmbeddingModel.apiKey` is now+ `Maybe ApiKeySource` rather than `ApiKeySource`. `Nothing` means the+ conventional environment variable for the model's host, from+ `defaultApiKeyEnvForBaseUrl` — the same table the chat providers use — and a+ host that table does not know refuses with an `AuthError` naming+ `EmbeddingModel.apiKey`. Migration: `apiKey = source` becomes+ `apiKey = Just source`. `EmbeddingModel` also derives `Eq` and `Generic`, so+ the `#field .~ value` idiom works on it as it does on every other record.+ (REV-2 E.3.)++- `baikai`: **breaking.** `AgentRunFailure`'s `RunTimedOut` constructor now+ carries a new record `AgentTimedOut` — the configured `limit` plus the+ `stdout` and `stderr` a timed-out run drained before its process group was+ killed — instead of a bare `NominalDiffTime`. A caller matching+ `RunTimedOut limit` becomes `RunTimedOut timedOut` and reads `timedOut ^.+ #limit`; `renderAgentRunFailure` is unchanged in what it says. The bytes were+ always there, drained from the moment the child was spawned, and were simply+ dropped on the timeout path — which is the run an operator most wants an+ account of, because the tool started, may have consumed tokens, and may+ already have changed the working tree.++- `baikai`: under `EvidenceRequired`, a successful terminal that carries no+ evidence record fails the call with `missingEvidenceError` rather than+ returning a silent success with zero `call_evidence` lines. Strict mode+ guaranteed that a record which was built and then lost fails the call; it did+ not guarantee that one was built. The rule is applied at both dispatch points,+ so `completeRequest` with no sink gets the same guarantee as a streaming call;+ a failed call keeps the provider's own error, and best effort is unchanged.+ See `docs/adr/0014-strict-evidence-means-a-record-exists.md`. (REV-2 D.3.)++- `baikai`: a caller's thinking level is recorded on every evidence path — the+ consumer abort, an unregistered provider, a `complete` handler that threw, and+ each provider's `immediateError`. The abort path asks the registered adapter's+ own `describeThinking`; the others record `not_translated`. All four used to+ record the caller's request as `absent`, which+ `docs/adr/0002-requested-translated-observed-are-never-collapsed.md` forbids.+ (REV-2 D.2.)++- **`baikai.model-call-evidence/2.0`.** Two digests cover different bytes, so a+ verifier must now select its rules by `schema_version`. `response_commitment`+ covers the provider-reported token counts and never baikai's computed cost:+ the cost comes from the caller's catalog rates rather than from the response,+ so the digest used to change whenever a price was edited and a verifier+ holding only the response could not recompute it. `request_configuration`+ summarises `output_config` and `response_format` as it already summarised+ `tools`, because a structured-output JSON schema carries author-written+ `description` strings and is content wherever it appears — the same schema was+ stripped from `tools[].input_schema` and survived verbatim through the other+ two keys. `thinking.mode` may also now be `"not_translated"`, which is a+ compatible addition. (REV-2 D.7, D.11.)++- **Breaking.** `baikai`: `Baikai.Provider.Registry.ApiProvider` gains a fifth+ field, `strengthCeiling :: EvidenceStrength`, and+ `Baikai.Evidence.Build.checkEvidenceRequirements` takes that ceiling where it+ took an `Api`. The gate compared against `declaredStrength`, a table keyed by+ the API tag, which necessarily answered `EvidenceRequestedOnly` for every+ `Custom` transport — so a gateway that genuinely observes a model could never+ satisfy a strict caller who required that it did. Only a provider knows what+ its evidence reaches. `EvidenceRequestedOnly` reproduces the old behaviour for+ any custom provider; the four built-in providers fill the field from+ `declaredStrength`, which is unchanged in value and still used by the+ unattended-agent surface. (REV-2 D.10, G.1.)++- `baikai`, `baikai-claude`, `baikai-openai`: one strength derivation replaces+ three. An observed **response id** now counts as correlation alongside a+ captured request-id header, so a host that names its model and its response id+ on every chunk but sends no header reaches `model_observed` instead of+ `requested_only` — which had put it *below* a host that sent only a header and+ named nothing. `anthropicStrength` and `openaiStrength` are removed;+ `Baikai.Provider.Cli.Internal.subprocessStrength` keeps its signature and+ delegates. (REV-2 D.10.)++### Removed++- `baikai` **0.6.0.0** (breaking): the sixteen `_Type` base-value aliases deprecated in+ 0.3.0.0 — `_Options`, `_Context`, `_Model`, `_ModelCost`, `_Response`,+ `_Usage`, `_Cost`, `_CostBreakdown`, `_Tool`, `_TextContent`,+ `_ThinkingContent`, `_ToolCall`, `_ImageContent`, `_EmbeddingModel`,+ `_InteractiveLaunchRequest` and `_InteractiveLaunchResult`. Each has an+ `empty…` or `zero…` replacement of the same value, named in the pragma that+ has been on it since 0.3.0.0. The 0.3.0.0 entry said they remained "for this+ release"; 0.4.0.0 and 0.5.0.0 shipped without removing them because no entry+ named a version.+ `docs/adr/0016-deprecated-names-are-removed-at-the-next-major.md` now fixes+ the rule: a name deprecated in `A.B.0.0` is removed in `A.(B+1).0.0`, and+ every pragma says so. (REV-2 G.3.)++- `baikai` **0.6.0.0** (breaking): `Baikai.Trace.newEventId`. It has delegated to+ `Baikai.Evidence.newCallId` since 0.5.0.0; call that. (REV-2 G.3.)++- `baikai` **0.6.0.0** (breaking): `Baikai.Compat.defaultAnthropicThinkingStyle`, deprecated+ earlier in this cycle. Nothing in baikai consults it — the thinking style of a+ first-party Anthropic model is a field of its generated catalog record+ (`Baikai.Models.Generated`); start from that value, or set+ `CompatAnthropicMessages` explicitly.++- `baikai` (breaking): `AgentRunRequest.envPassthrough` is renamed `envRequires`.+ The field is a list of variables the job declares it requires, checked as a+ precondition; it has never passed anything through, and the KDL key has said+ `env-requires` since the setting existed.++- `baikai` (breaking): `AgentRunFailure.OutputMalformed`, and with it+ `baikai-agent`'s exit code 70 and its `internalExitCode` export. Nothing ever+ constructed the constructor, and giving it a producer would have been wrong:+ the runner treats the tool's output as best-effort observation and its+ deliverable is the changed working tree, so a run that edited files correctly+ and then printed an unparseable final line would have been reported as a+ failure with its exit code and output discarded. A record's `strength` and+ `unobserved` fields already say when output could not be read. (REV-2 F.13.)++### Fixed++- `baikai`: the terminal event and its evidence record are pushed to the trace+ sink exactly once under asynchronous exceptions. The terminal path pushed the+ evidence record, pushed the terminal event and only then set the+ already-sent flag; an exception delivered between the last two made the+ stream finaliser read the flag as unset and push a second `CallEvidence` and+ an `aborted` `CallFailed` after the real `CallFinished`, so a sink saw two+ records and two contradictory terminals for one call. All three writes now+ run inside one `uninterruptibleMask_` with the flag first. (REV-2 D.4.)++- `baikai`: `Baikai.Cost.Log.closeCallLog` is idempotent. The first caller+ claims the handle and waits for the worker; a second returns at once instead+ of blocking forever on an `MVar` the worker had already emptied — a shape+ `withCallLog` makes easy to reach, since its bracket closes a handle the body+ may also have closed. An `appendEntry` after the close enqueues nothing.++- `baikai`: `reassembleResponse` is total under duplicated, late and+ timestamp-less input. The first `EventStart` wins the skeleton and+ `responseId` merges with `<|>`, so a later `Nothing` cannot erase an id an+ earlier event supplied; events after the first terminal are ignored, so a+ producer that keeps talking cannot rewrite the answer; and `latencyMs` falls+ back to the reassembler's own wall clock when neither the skeleton nor the+ terminal carries a provider timestamp, instead of reporting a zero that reads+ as "instant". (REV-2 B.7.)++- `baikai`: an `EmbeddingModel` pointed at a non-OpenAI host no longer sends+ `OPENAI_API_KEY` to it. The default key source was that variable whatever the+ base URL said, so pointing the client at DeepSeek handed DeepSeek an OpenAI+ credential. It now resolves per host, and refuses an unknown one. New+ `resolveEmbeddingKey` and `embeddingClientEnv` expose both decisions without+ making a request. (REV-2 E.3.)++- `baikai`: `Baikai.Embedding.embed` no longer allocates a TLS manager per call.+ It used the `openai` SDK's own `getClientEnv`, which builds a fresh manager+ every time; it now takes one from `Baikai.Http`'s process-global cache, the+ same one the chat providers use, so an embedding call and a chat call to one+ host share a connection pool.++- `baikai`: **a credential in a header is no longer printed.** `Options.headers`+ and `Model.headers` went through derived `Show` and `ToJSON` instances that+ rendered every value verbatim — while `Baikai.Options`' own documentation+ invites callers to put a gateway's `Authorization` header there and the+ getting-started guide tells them to `print resp`, which renders the embedded+ `Model`. Both types now have hand-written instances that render exactly what+ the derived ones did, except that the value of a header whose name looks+ credential-carrying (`authorization`, `api-key`, `apikey`, `token`, `secret`,+ `cookie`, `password`, or any name ending in `-key`, case-insensitively) prints+ as `<redacted>`. `Baikai.Auth` exports the three pieces — `redactedMarker`,+ `isCredentialHeader`, `redactHeaderValues` — so a caller can apply the same+ rule to its own logging. Only the rendering changes: the field is untouched,+ `Eq` is untouched, and the header is still sent as written. A JSON round trip+ of a `Model` is deliberately lossy, since a serialised `Model` is exactly the+ thing that should not carry a key. (REV-2 E.2.)++- `baikai`: an API-key environment variable set to the empty string, or to+ nothing but whitespace, now counts as **unset**. `ApiKeyEnv` fails with an+ `AuthError` naming the variable and saying it is not set or is empty;+ `ApiKeyEnvChain` skips it and continues, and reports every name when none+ yields a key. Previously an empty variable resolved to an empty key, which+ short-circuited a chain and produced `Authorization: Bearer ` and a provider+ 401 that said nothing about the cause. A key with real content is still passed+ through untrimmed. (REV-2 E.6.)++- `baikai`: **the host parse no longer lets a base URL choose which key baikai+ sends.** `urlHost` took the text after the *last* `@` anywhere in a URL, so+ `https://proxy.example.com/v1?u=@api.openai.com` named the host+ `api.openai.com`: `defaultApiKeyEnvForBaseUrl` resolved `OPENAI_API_KEY`,+ `autoDetectOpenAICompletions` returned OpenAI's own compatibility record, and+ the bearer token went to `proxy.example.com`. Anyone who could set `baseUrl` —+ a `Model` decoded from JSON, a proxy override — could pick which provider's+ credential to be handed. The same defect broke the benign direction:+ `https://api.openai.com/v1/@x` named the host `x` and resolved no key at all.+ The authority now ends at the first `/`, `?` or `#`, and userinfo is only ever+ the last `@` inside it. (REV-2 A.1 / E.1.)++- `baikai`: `Baikai.Evidence.Build.sanitizeEndpoint` was a second, separately+ written parser that bounded the authority at the first `/` only, so a URL with+ a query and no path recorded the wrong host. It is now `renderEndpoint <$>+ parseUrl`, which also means a recorded endpoint has a lower-cased scheme and+ host; the path keeps its case and trailing slash.++- `baikai`: `parseCodexJsonlStream` assembles lines in **linear time**. It+ previously unpacked every chunk into a stream of bytes and appended them one+ at a time with `BS.snoc`, copying the whole accumulator per byte — quadratic+ in line length, so one codex event carrying a two-million-character message+ cost on the order of a trillion byte moves and in practice never finished.+ Lines are now cut out of each chunk with `BS.elemIndex` and `BS.splitAt`, and+ the pieces of a line that spans a chunk boundary are joined once. Behaviour is+ unchanged: a non-JSON line is still skipped, and a last line without a+ trailing newline is still parsed.++- `baikai`: a Codex custom agent's instructions body renders as a TOML+ **literal** multi-line string (`'''`), which interprets nothing, instead of a+ basic one (`"""`), which interprets backslash escapes. As a basic string an+ instruction as ordinary as "match `\d+`" made Codex refuse to load the file;+ `tomllib` rejects the old output with `Unescaped '\' in a string`. A body a+ literal string cannot hold — one containing three apostrophes, a bare carriage+ return, or a control character other than tab and newline — falls back to a+ fully escaped basic string. `tomlString`, which renders `name` and+ `description`, now escapes every control character as TOML 1.0 requires+ instead of only the five it happened to name.++- Documentation: `baikai`'s Haddock no longer describes behaviour the code left+ behind. The trace event's token counts are `Maybe` because a non-assistant+ terminal has no usage, not because the CLI providers report nothing — since+ 0.5.0.0 both carry what the tool reported. `EventStart`'s `partial` is a+ message skeleton with empty content, zero usage and no stop reason; the api,+ provider and model id live on the `Response`. A lifted stream's `EventStart`+ carries the final usage and stop reason already filled in, because the+ response is complete before the stream begins. `Baikai.CacheRetention` no+ longer mentions an OpenAI Responses 24-hour bucket no code emits. System+ prompts are documented as living on `Context.systemPrompt` rather than on a+ `Baikai.Request` module that no longer exists, `emptyModel`'s `compat` is+ described as auto-detection rather than a placeholder, tool dispatch says+ calls run one at a time in order, and every reference to a plan number is+ gone. (REV-2 H.4.)++## [baikai-claude 0.6.0.0] - 2026-08-28++### Added++- `baikai-claude`: `Baikai.Provider.Claude.Internal.Request` exports `planRequest`,+ `SamplingPlan`, `uncappedMaxTokensFloor` and `normalizeToolCallId` as test seams.+ `planThinking` and `describeThinkingFor` are now projections of `planRequest`, so the+ strict gate, the request builder and the evidence record read one answer.++### Changed++- `baikai-claude`, `baikai-openai` (breaking): each provider's streaming+ machinery moved from `Baikai.Provider.<P>.Api` to+ `Baikai.Provider.<P>.Internal.Stream` — the `SseDriver` seam, `liveSseDriver`,+ `<p>StreamWith`, `Assembler`, `emptyAssembler`, `translate`, and on the OpenAI+ side `RawChunk`, `RawToolDelta`, `parseChunk`, `parseFrame`, `TagScanState`,+ `scanThinkTags`, `closeOpenStream`, `RawUsage`, `parseUsage` and+ `rawUsageToUsage`. `Api` now exports exactly `register`, the provider value+ and the live stream function. The `.Internal` module is exposed for the test+ suites and sibling packages and, like every `.Internal` module, may change in+ any release without a major bump — so changing the assembler stops being a+ documented break. `Shape`, `Sse` and `Transport` keep their names and gain the+ same no-guarantees header. `_TagScanState` is renamed `emptyTagScanState`.+ (REV-2 G.1.)++- `baikai-claude`, `baikai-openai`: a consumer that stops reading now stops the+ provider. Both packages fork their SSE worker under `Stream.bracketIO` and+ hand frames through the bounded `FrameQueue` above instead of an unbounded+ `Chan`. A consumer that cancels — `Ctrl-C`, `System.Timeout.timeout`,+ `cancel` — releases the HTTP connection immediately; a consumer that abandons+ the stream (`Stream.take 3`) stops the socket read within 64 further frames+ and releases the connection at the next major garbage collection. Previously+ the worker read the entire generation into memory for a consumer that would+ never look at it, and the provider billed all of it. The three cleanup+ strengths are stated in+ [docs/adr/0010](docs/adr/0010-a-stream-consumer-that-stops-owns-cancelling-the-producer.md)+ and in caller terms in `docs/user/streaming.md`.++- `baikai-claude`: `anthropic_claude_sonnet_4_6` now sends the adaptive+ thinking shape rather than `budget_tokens`. The budget shape is deprecated+ for that generation; baikai sends the shape Anthropic documents as current.++- `baikai-claude`, `baikai-openai`: **behaviour change.** `Options.timeoutMs` of+ `Just n` with `n <= 0` is refused as `InvalidRequest` before the action runs, so+ no connection is opened. `System.Timeout.timeout` returns immediately at zero+ and runs unbounded below it, and the previous `max 0` clamp made both spellings+ fail instantly as a *retryable* `TransientError` — a classification a caller's+ retry loop re-issues forever for what is a configuration mistake. `Nothing`+ remains the only spelling of "no bound". (REV-2 A.10.)++- `baikai-claude`, `baikai-openai`: an evidence record's `endpoint` names the+ host the call actually went to. Both adapters substitute a vendor default for+ an empty `Model.baseUrl` inside `prepareCall`, so a call with a perfectly+ definite destination recorded `endpoint: null`. Where no adapter ran, `null`+ remains the truthful answer. (REV-2 D.8.)++- `baikai-claude`: the `claude` dependency moves from `^>=1.4` to `^>=1.5`.+ 1.5.0 adds a `Pause_Turn` constructor to `Claude.V1.Messages.StopReason`, and+ `mapStopReason` matches that type with no wildcard under+ `-Werror=incomplete-patterns`, so the bump forced a decision. A paused turn+ maps to `Stop`: Anthropic suspends the turn mid-flight for a long-running+ server-side tool and expects the caller to send the message back to continue+ it, so nothing failed, and `Baikai.StopReason` has no constructor that says+ "resume me". Widening that public sum is a breaking change for every consumer+ who matches on it exhaustively, and it is not this bump's to make. The general+ rule is+ [ADR 0018](docs/adr/0018-a-provider-stop-reason-with-no-baikai-equivalent-maps-to-the-nearest-truthful-one.md):+ a provider stop reason with no baikai equivalent maps to the constructor that+ is truthful about whether the call failed, and the sum widens only when baikai+ would behave differently for it.++- `baikai-claude`: `Messages.StreamUsage` lost its `Generic` instance in `claude`+ 1.5.0, so the `message_delta` usage is read through `OverloadedRecordDot`+ rather than a generic-lens label. `Messages.max_tokens` and+ `Messages.output_config` became ambiguous selectors — `Messages.Fallback`+ carries both names — so the provider's tests read them through `^. #max_tokens`+ and `^. #output_config` instead.++### Removed++- `baikai-claude`, `baikai-openai` **0.6.0.0** (breaking): the eight registration shims —+ `registerWith`, `registerWithRegistry` and `registerWithRegistryAndConfig` in+ both `Cli` modules, and `registerWithRegistry` in both `Api` modules. Register+ the exported provider value instead:+ `registerApiProvider (claudeCliProvider cfg)`,+ `registerApiProviderWith reg (codexCliProvider cfg)`,+ `registerApiProviderWith reg claudeMessagesProvider`. The batch-mode note that+ had accumulated on `registerWith` — why `complete` stays on the direct path+ rather than going through `streamingComplete` — moves to the provider value it+ describes. (REV-2 G.3.)++- `baikai-claude`, `baikai-openai`: `responseToError` and `classifyErrorText`+ (and its private `classifySdkHttpText` half) from both+ `.Internal.ErrorClass` modules. Neither package runs a `servant-client` client+ on the chat path any more, so the `ClientError` branch was unreachable, and the+ text classifiers parsed a string shape the local SSE transports stopped+ producing in July. The phrase table `classifyErrorText` held survives as the+ message fallback inside `classifyErrorFrame`, pinned through the entry point the+ runtime actually uses. Both modules are documented as outside the PVP-stable+ surface, so this is not a major bump; version bumps are recorded once, later.++- **Breaking.** `baikai-claude`: `Baikai.Provider.Claude.Api.anthropicStrength`+ and `baikai-openai`: `Baikai.Provider.OpenAI.Api.openaiStrength`, both replaced+ by `Baikai.Evidence.deriveStrength`.++### Fixed++- `baikai-claude`, `baikai-openai`: a failure that lands while the response body+ is streaming is classified as the transient failure it is. A connection reset,+ a server closing the socket mid-chunk, a body shorter than its declared length+ and a TLS session torn down after the handshake all now terminate the stream+ with `TransientError` and `isRetryable = True`, carrying whatever text had+ already been drained. Every one of them used to be `OtherError` with+ `isRetryable = False`, while the identical failure at connect time was+ transient — because `http-client` wraps the connect phase with the manager's+ exception wrapper and the body reader with nothing that converts a socket+ `IOException` or a `TLSException`, so those reached the worker raw and missed+ the `HttpException` branch entirely. (REV-2 A.2.)++- `baikai-claude`, `baikai-openai`: a transport failure mid-stream now closes+ the blocks that were open when it arrived, on both providers, so a consumer+ reading raw events and a consumer reassembling them see the same partial+ output. Both providers built their terminal from the closed blocks alone and+ silently dropped open text, thinking and tool arguments. On the Claude side+ this covers `translate (Left …)`, the in-band `error` frame, and the+ unexpected end of stream. (REV-2 B.3.)++- `baikai-claude`: an SSE frame whose event `type` — or whose+ `content_block_delta` `delta.type` — the SDK has no constructor for is now+ skipped instead of ending the stream with a decode error. The SDK decodes both+ with no unknown-tag fallback, so a new frame type from Anthropic used to be a+ terminal fault. A frame of a *known* type that still fails to decode remains+ one. `Baikai.Provider.Claude.Sse` exports the new `decodeFrame`. (REV-2 B.5.)++- `baikai-claude`, `baikai-openai`: an empty `data:` heartbeat is ignored, and+ on the OpenAI side `[DONE]` is compared after trailing whitespace is trimmed,+ so `data: [DONE] ` and `data: [DONE]\r` end the stream rather than failing to+ decode. (REV-2 A.8.)++- `baikai-claude`: every failing stream now begins with `EventStart`. The+ producer pre-seeds the start event before the first wire read, exactly as the+ OpenAI producer already did, and `message_start` updates the assembler without+ emitting a second one. Previously a 401, a rate limit, an in-band `error`+ frame or an EOF arriving before `message_start` produced a lone `EventError`,+ breaking the protocol `Baikai.Stream.Event` documents. `StartPayload.responseId`+ is consequently `Nothing` on both HTTP providers; the provider's message id+ rides `TerminalPayload.responseId`, which `reassembleResponse` already prefers.+ (REV-2 A.4, REV-1 Theme 1.1.)++- `baikai-claude`, `baikai-openai`: an asynchronous exception delivered to the+ stream worker can no longer strand its consumer. End-of-frames is a flag set+ by the worker fork's own `finally` rather than a sentinel value pushed onto+ the channel, so a worker that dies without running its normal exit path still+ ends the stream in an `EventError`. Previously the consumer blocked until the+ runtime's deadlock detector noticed.++- `baikai-smoke`: two keyed cases against `claude-sonnet-5` — one asking for+ thinking (which is a 400 before this release) and one setting `temperature` — plus+ `deepseek-chat` and `openrouter/openai/gpt-4o-mini` in `apiCases`, so the tool and+ structured-output smokes run against a compatible host that is not OpenAI.+ `CompatSmoke` now asserts DeepSeek honoured the output cap rather than only that it+ answered, and `CacheSmoke` asserts the cached token classes cost something.++- `baikai-claude`: a thinking request on `claude-sonnet-5` no longer 400s. It sends+ `"thinking":{"type":"adaptive"}` and no `budget_tokens`, because the shape is read off+ the model's catalog record rather than guessed from its id. (REV-2 C.1.)++- `baikai-claude`: `temperature` and `top_p` are no longer sent to a model generation that+ rejects them with a 400. They are omitted and the omission is recorded as+ `sampling_dropped_unsupported_model` in the call's evidence. `seed`, `frequencyPenalty`+ and `presencePenalty`, which the Anthropic Messages API has no field for on any+ generation, are recorded as `sampling_dropped_unsupported_api`. (REV-2 C.1, C.5.)++- `baikai-claude`: a model whose `maxOutputTokens` is `0` no longer sends+ `"max_tokens":0`, which Anthropic rejects — and, with thinking set, no longer had its+ whole thinking plan discarded for not fitting inside a ceiling of zero. It sends+ `uncappedMaxTokensFloor` (1024, the SDK's own default) instead. An explicit+ `maxTokens = Just 0` is still forwarded as written. (REV-2 C.2.)++- `baikai-claude`: replay no longer sends an empty text block or an empty `content` array,+ both of which Anthropic rejects. An empty text block is dropped; an assistant turn left+ with nothing is dropped whole (it is baikai's own artifact — a block that closed with no+ deltas, or only unsigned thinking, which replay already omits); a user turn left with+ nothing is refused locally with a message naming the turn. (REV-2 C.3.)++- `baikai-claude`: tool-call ids that differ only in characters the alphabet forbids, or+ only past character 64, no longer normalise onto the same id and misroute a tool result.+ A conforming id passes through unchanged — every id Anthropic and OpenAI actually mint+ does — and any other is truncated to 51 characters and suffixed with twelve hex+ characters of its SHA-256. Two `tool_use` blocks in one turn that still collide are+ refused rather than sent. (REV-2 C.7.)++- Documentation: `baikai-claude`'s and `baikai-openai`'s Haddock point at the+ functions that exist. `Baikai.Compat` named+ `Baikai.Provider.OpenAI.Api.mkOpenAIResponseFormat`,+ `…Api.applyThinkingFormat` and `…Api.translateTextLikeDelta`; the first two+ moved to `…Internal.Request` and the third is+ `…Internal.Stream.scanThinkTags`. `ThinkingFormat`'s note said the six+ non-native shapes all clamp through `compatibleEffort`; three do, Z.ai and+ Qwen send a bare toggle, and `ThinkingFormatNone` drops the control.+ `immediateError` carried two `-- |` headers where one was intended.+ (REV-2 H.4.)++- `baikai-claude`: an Anthropic call reports its thinking tokens. `Usage.reasoningTokens`+ was hard-coded to `Nothing` on this provider because `claude` 1.4.0's+ `Messages.Usage` had no breakdown to read; 1.5.0 adds+ `output_tokens_details.thinking_tokens`, and both `message_start` and+ `message_delta` now fill the field from it. `reasoningTokens` is an+ informational subset of `outputTokens`, so no total and no cost moves.++- `baikai-claude`: the prompt-side token counts survive a server-side tool run.+ The final `message_delta` used to contribute only `output_tokens`, and+ `inputTokens`, `cacheReadTokens` and `cacheWriteTokens` kept whatever+ `message_start` had reported — which is wrong for a call whose prompt grew+ mid-stream. `claude` 1.5.0 exposes those three on `Messages.StreamUsage`, and+ each is now taken when present. An absent field still keeps the+ `message_start` figure rather than zeroing it, so a model that sends only+ `output_tokens` is accounted for exactly as before.++## [baikai-openai 0.6.0.0] - 2026-08-28++### Added++- `baikai-openai`: `Baikai.Provider.OpenAI.Internal.ErrorClass.classifyErrorFrame`+ and `Baikai.Provider.OpenAI.Api.parseFrame`, which sort a decoded SSE payload+ into a classified in-band error or a completion chunk.++### Changed++- `baikai-openai`: **breaking.** `Baikai.Provider.OpenAI.Shape`'s+ `injectThinkingShape`, `describeThinkingShape`, `shapeRequestBody` and+ `streamRequestBody` take a `Bool` after the compat record — whether the model+ advertises reasoning support (`Model.reasoning`). A level on a `reasoning = False`+ model now sends no `reasoning_effort`, `reasoning`, `thinking` or `enable_thinking`+ key on any host, and records `thinking_dropped_unsupported_model` instead. The model+ check runs before the host-format check. This is what stops `gpt-4o-mini` plus a+ level from 400ing. (REV-2 C.4.)++### Fixed++- `baikai-openai`: an in-band `{"error": …}` frame on a `2xx` stream terminates+ the call with the frame's own classification, status and message. Compatible+ hosts (OpenRouter, DeepSeek, Together) report an upstream failure they only+ learned about after committing to a `200` this way, and `parseChunk` never+ looked at `error`. The pre-fix behaviour was worse than a bad category:+ OpenRouter's frame carries `choices[0].finish_reason = "error"`, which mapped+ to `Stop`, so the call ended as `EventDone` with `errorInfo = Nothing` — a+ consumer switching on the terminal saw a *completed* call. A frame with no+ `choices` beside the error ended as+ `OtherError "openai stream ended without finish_reason"`. (REV-2 A.3.)++- `baikai-openai`: reasoning that arrives after visible text closes the open+ text block before opening the thinking block, so at most one of the two is+ open at a time, every `_End` precedes the next `_Start`, and no `contentIndex`+ is revisited after a later one. (REV-2 B.4.)++- `baikai-openai`, `baikai-claude`: **a provider POST no longer follows+ redirects.** `http-client`'s default is to follow up to ten with every header+ intact, so a 3xx would have re-sent the bearer token (or `x-api-key`) to+ whatever host the `Location` header named. `redirectCount` is now zero and the+ 3xx is delivered as the one in-band terminal error carrying its status. Each+ transport's request builder is exported as `buildRequest`, so the method, the+ composed path and the redirect policy are assertable without a connection.+ (REV-2 A.5 / E.4.)++- `baikai-openai`, `baikai-claude`, `baikai`: **the base-URL convention is+ stated and enforced.** `Model.baseUrl` and `EmbeddingModel.baseUrl` are the+ API *root* — the host, or the prefix a host mounts the API under — because+ baikai appends `/v1/chat/completions`, `/v1/messages` or `/v1/embeddings`+ itself. A trailing `/v1` is accepted and removed rather than doubled, so+ `https://api.deepseek.com/v1` now requests `/v1/chat/completions` instead of+ `/v1/v1/chat/completions`. A base URL with no scheme, a scheme other than+ `http`/`https`, credentials, a query string, a fragment, or a path that is+ already an endpoint is refused as an `InvalidRequest` naming the problem —+ and refused *before* a key is read, so an unusable base URL never causes a+ credential to be looked up. The message renders the URL without its userinfo+ or query, so it is safe to log. `docs/user/models-and-providers.md` gains a+ **Base URLs** section stating all of it. (REV-2 A.6.)++- `baikai-openai`, `baikai-claude`: the `ClientEnv` cache was duplicated in each+ package and keyed on the raw base-URL text, so `https://h` and `https://h/`+ were two TLS managers and two connection pools to one host. There is now one+ cache, in `Baikai.Http`, keyed on the canonical rendering of the parsed base+ URL. `Transport.getClientEnvCached` and `Transport.cachedClientEnvCount` are+ re-exports of the core functions and keep their signatures.++- `baikai-openai`: the Codex interactive launcher now **refuses the two approval+ policies the installed CLI rejects**. `codex --help` at `codex-cli 0.149.1`+ lists exactly `on-request` and `never` for `--ask-for-approval`;+ `CodexApprovalUntrusted` and `CodexApprovalOnFailure` are older spellings the+ CLI answers with `error: invalid value 'untrusted' for+ '--ask-for-approval'`. Rendering them made a launch return `Right` carrying a+ non-zero exit code — a session that ran and failed — instead of the `Left+ SafetyNotExpressible` this module promises for a policy that cannot be+ honoured. They are refused before any process is created, and refused rather+ than quietly mapped onto `on-request`, because substituting a different+ approval policy would change what the caller asked for. The constructors and+ their spellings are unchanged, so code that matches on `CodexApprovalPolicy`+ keeps compiling.++## [baikai-trace-otel 0.4.0.0] - 2026-08-28++### Added++- `baikai-trace-otel`: `OtelSinkOptions` derives `Generic`, so `#spanName`+ resolves on it. No `Eq` or `Show`: `OpenTelemetry.Context.Context` has neither,+ and an instance that ignored `parentContext` would be a lie. (REV-2 G.6.)++- `baikai-trace-otel`: `OtelSinkOptions.parentContext :: Maybe Context`, default+ `Nothing`. When set, every span the sink opens becomes a child of the span in+ that context instead of a root, so a call can be nested under the caller's own+ request span. It is a value fixed when the sink is built rather than an action+ run per call, because the fold runs on baikai's trace worker thread where the+ caller's thread-local context is invisible: capture the context on your own+ thread (`ctx <- getContext`, or `Context.insertSpan mySpan Context.empty`) and+ build the sink for that request. __Breaking for positional construction__ of+ `OtelSinkOptions`; the documented path is a record update on+ `defaultOtelSinkOptions`. (REV-2 D.9.)++### Changed++- `baikai-trace-otel`: the `baikai.evidence.strength` span attribute is rendered+ by `Baikai.Evidence.renderEvidenceStrength`, the function the JSON encoding+ uses, instead of a second spelling local to the sink that could drift from it.++- `baikai-trace-otel`: `gen_ai.response.model` is set only by the evidence+ branch, from the model the provider reported. The terminal branch set it from+ the *requested* id, and since evidence is pushed before the terminal and+ `addAttributes` replaces a key, that both labelled a request as an observation+ on every call without evidence and overwrote the genuinely observed value on+ every call with one. (REV-2 D.1.)++## [baikai-effectful 0.4.0.0] - 2026-08-28++### Changed++- `baikai-effectful` (breaking): the version is a **major** bump although this+ package's own exports are unchanged. Its `baikai` bound moves to `^>=0.6.0`,+ and the `Baikai` effect's three operations are typed in `Model`, `Context`,+ `Options` and `Response` — every one of which baikai 0.6.0.0 changes+ breakingly. A consumer therefore meets a break through this package even+ though nothing in it was renamed, so the number says so rather than making+ `0.3.0.4` look like a safe upgrade.++- `baikai-effectful`: no longer depends on `streamly`. Both stanzas listed it+ while every module imports only `Streamly.Data.Fold` and+ `Streamly.Data.Stream`, which are `streamly-core`. (REV-2 minor.)++## [baikai-kit 0.2.0.0] - 2026-08-28++### Added++- `baikai-kit`: `Baikai.Kit.Error` with the closed `KitError` sum, its+ `Exception` instance and `renderKitError`; `Baikai.Kit.Path.safeSourcePath`,+ which resolves an untrusted relative source below the kit checkout and refuses+ a symbolic link in any component or a canonical path outside the checkout;+ `Baikai.Kit.Manifest.itemSources`/`ItemSources`, the one pure derivation of an+ item's source list, and `supportedManifestVersions`;+ `Baikai.Kit.Sidecar.hashEntries`; `Baikai.Kit.Repo.KitRepo`/`RepoRefresh`;+ `Baikai.Kit.Install.installFrom`, `renderAvailable` and `UpdateReport`;+ `Baikai.Kit.Status.StatusReport`, `UpstreamAvailability` and the now-pure+ `renderStatusTable`; `Baikai.Kit.Command.runKitCommand`. `KitState` gains+ `KitUpstreamRefused`, rendered `refused`. (REV-2 E.5, F.10, F.11.)++- `baikai-kit`: `Baikai.Kit.Install.OverwritePolicy` (`KeepLocalEdits`,+ `OverwriteLocalEdits`), `reinstallPresent` (the network-free half of+ `updateKit`), and `PlannedWrite`/`WriteContent`/`executePlan`/`executePlanWith`+ as a test seam. `SidecarMeta` gains `installedFiles` and `installedHash`,+ which record what this tool wrote for one provider and the hash of exactly+ those bytes; `newSidecarMeta` takes both. `kit update` gains `--force`.+ (REV-2 F.12, Theme 8.2.)++### Changed++- **Breaking.** `baikai-kit`: every library function returns+ `Either KitError a` and prints nothing; only+ `Baikai.Kit.Command.runKit` prints `Error: …` and exits 1. `loadManifest`,+ `loadManifestMaybe`, `installItem`, `listAvailable`, `uninstallItem`,+ `updateKit` and `ensureKitRepo` change shape accordingly, `computeKitHash`+ takes the kit root, a base and relative file names, `kitStatus` returns a+ `StatusReport` instead of printing, and `KitUpdate`'s report is rendered by+ the caller. See `docs/adr/0013-library-code-never-calls-exitfailure.md`. A+ consumer that only calls `runKit` and `kitCommandParser` needs no change; one+ that calls the library directly binds `Right`. (REV-2 F.11.)++- `baikai-kit`: a kit is plain files. Install, the content hash and `kit status`+ resolve every listed source through `safeSourcePath`, so a kit repository that+ commits a symbolic link can no longer have a file read through it and copied+ into a provider directory. `kit status` shows such an item as `refused`.+ (REV-2 E.5 = F.10.)++- `baikai-kit`: a manifest whose `version` is not 1 or 2 is refused with+ `KitManifestVersionUnsupported` instead of being decoded and installed.+ (REV-2 F.12.)++- `baikai-kit`: an agent that lists several `files` installs all of them. The+ first becomes the provider's agent file as before, and each remaining file+ goes into a resource directory named after the agent beside it+ (`<agents dir>/<name>/<file>`), which uninstall removes with the agent. Only+ the first file used to be installed. (REV-2 F.12.)++- `baikai-kit`: `kit update` skips an item whose installed files no longer hash+ to what its sidecar recorded, printing the `--force` invocation that would+ overwrite them; `kit update --force` reinstalls anyway. Sidecars written+ before this release carry no such hash and are updated without the check.+ (REV-2 Theme 8.2.)++### Removed++- **Breaking.** `baikai-kit`: `Baikai.Kit.Path.safeUnder` (exported and unused),+ `Baikai.Kit.Manifest.agentSources` (replaced by `itemSources`) and+ `Baikai.Kit.Install.uninstallOutcomes` (absorbed by `uninstallItem`, which now+ returns the outcomes for the caller to render). The internal `requireSafe` and+ `Baikai.Kit.Status.resolveCacheOrEmpty` are gone with the exits they wrapped.++### Fixed++- `baikai-kit`: `kit status` with no cache and no network prints+ `No kit items installed.` and exits 0. It used to exit 1: the guard around+ `ensureKitRepo` caught `IOException`, which is not what `exitFailure` throws.+ (REV-2 F.11.)++- `baikai-kit`: `Baikai.Kit.Status.upstreamHash` joined the manifest `path`+ without validating it, a second unsanitised join that grew after the July+ hardening pass validated the first. Both now go through `itemSources` and+ `safeSourcePath`. (REV-2 Theme 8.1.)++- `baikai-kit`: an install that fails while renaming files into place now+ restores what was there before, or names the paths it could not restore.+ Phase two was a bare loop of renames, so a failure part-way left earlier+ renames in place while the message said "no changes were made". Temporary+ files are also created with `openTempFile`, so two concurrent installs of one+ item no longer clobber each other's staging file, and a destination that is a+ directory is refused before anything is written. (REV-2 F.12.)++- `baikai-kit`: `Baikai.Kit.Install.stripYamlFrontmatter` normalises line+ endings to LF on every branch. Input without frontmatter, and input whose+ frontmatter is never closed, used to keep their `\r` characters and leak them+ into the Codex agent TOML. (REV-2 Theme 8.7.)++- `baikai-kit`: an `IOException` raised while reinstalling during `kit update`+ is returned as `KitWriteFailed` instead of escaping as an uncaught exception.+ (REV-2 Theme 8.4.)++## [baikai-agent 0.2.0.0] - 2026-08-28++### Added++- `baikai-agent`: three operator-only `policy` keys — `policy.allowed-tools`,+ `policy.max-timeout` (a duration or `"unlimited"`) and+ `policy.max-output-limit` (a byte count or `"unlimited"`) — each defaulting+ from `defaultAgentCeiling`, and all six ceiling fields now printed by+ `agent show` and carried in its `--json` object.++- `baikai-agent`: `Baikai.Agent.Config.repositoryScopeViolations`, which reads+ the resolution report to say which values the untrusted repository file was+ not allowed to supply at all. `Baikai.Agent.Cli` concatenates its answer with+ the pure ceiling's, so an operator sees one refusal naming every problem.++### Changed++- `baikai-agent` (breaking): `AgentConfigScope`'s constructors are+ `AgentUserScope` and `AgentRepositoryScope`. `UserScope` collided with+ `baikai-kit`'s `KitScope` constructor of the same name, the one clash between+ two baikai-family packages. (REV-2 G.5.)++- `baikai-agent` (breaking): a relative `working-dir` resolves against the+ repository root rather than the process's own directory, so `working-dir "."`+ means the checkout whichever file declared it. Resolving against the process+ directory made `"."` mean two places when two documents defined one job, since+ which one it was depended on which layer won. An absolute path is unchanged.+ (REV-2 F.14.)++- `baikai-agent` (breaking): every `--json` output is now built with `aeson`+ rather than a hand-rolled writer, and `agent show --json` always emits one+ object with the same seven keys — `job`, `outcome` (`shown`, `refused` or+ `failed`), `exitCode`, `message`, `configuration`, `ceiling`, `command` —+ with `null` for the parts that do not apply. Previously a refusal emitted a+ different shape from a success and a document that would not parse emitted a+ bare resolution report or nothing at all, so a reader had to know which+ failure mode it was looking at before it could find the exit code. `run --json`+ keeps its `outcome` values and `list --json` is unchanged. (REV-2 F.14.)++- `baikai-agent` (breaking): `--run-id` or `--require-evidence` without either+ `--evidence-file` or `--json` is now a usage error (64) naming both fixes.+ Before, the record was built — a `--version` probe of the tool and two digests+ — and then dropped. Under `--json` the record now travels in the envelope as+ `evidence`, encoded by the same `ToJSON` `--evidence-file` writes.++- `baikai-agent`: `agent show` and `agent run` no longer print another job's+ unknown-key warnings, or the operator file's `policy` keys. The declaration+ describes one job and the ceiling is a separate declaration, so `settei` warns+ about both; neither is a mistake and a document with four jobs printed three+ jobs' worth of noise on every run. A misspelled key inside the selected job+ still warns, and a `policy` node in the *repository* document earns exactly one+ notice saying it has no effect. `Baikai.Agent.Config` exports the two filters,+ `relevantWarnings` and `repositoryPolicyNotice`. (REV-2 F.13.)++- `baikai-agent`: an evidence record's `endpoint` resolves a relative executable+ against the job's working directory before probing it, because that is what+ the child execs. A job whose `executable` is `./bin/agent` previously reported+ a path resolved against the parent's own directory, which does not exist.+ `Baikai.Agent.Run` exports `executableForEvidence`. (REV-2 F.13.)++- `baikai-agent`: a failed run's `error_info.message` keeps the last+ `errorInfoStderrTailBytes` (4096) bytes of standard error, prefixed with how+ many earlier bytes were dropped, instead of the whole captured stream — which+ the output limit allows to reach four mebibytes by default. `Baikai.Agent.Run`+ exports the constant. (REV-2 F.13.)++- `baikai-agent`: `--evidence-file` stages through a uniquely named temporary+ file created with `O_EXCL` beside the destination, instead of the destination+ plus `.partial`. A symbolic link planted at the old, guessable name was+ followed, which let an unattended run overwrite a file of the planter's+ choosing. (REV-2 F.13.)++- `baikai-agent` (breaking): an operator configuration file that lies inside the+ repository root is refused with exit 78, naming the file and the root, and no+ ceiling is established. The source list already refused the repository+ *document*; this closes the shape where the repository supplies the *operator*+ document, which both `--user-config .baikai/policy.kdl` and+ `XDG_CONFIG_HOME=$PWD/.baikai` produce. `--user-config`, `XDG_CONFIG_HOME` and+ `HOME` remain the operator's own inputs: the ceiling is exactly as trustworthy+ as the process environment that selects it, and the guide now says so.+ (REV-2 F.4.)++- `baikai-agent` (breaking): an unrecognised key under the operator file's+ `policy` node is an error rather than a warning, naming the file and every+ such key. Everywhere else a forward-compatible file should not stop an older+ binary; under `policy` a misspelling would silently leave the default ceiling+ in force, which for the one node whose purpose is limiting authority is+ indefensible. Two `AgentConfigError` constructors are added,+ `CeilingFileInsideRepository` and `UnknownPolicySetting`.++- `baikai-agent` (breaking): `AgentConfigPaths` gains `repositoryRoot`, the+ directory the process runs in. `--config PATH` chooses which file supplies+ repository-scope settings and does not move the root, because the root is what+ confines a repository-supplied `working-dir`.++- `baikai-agent` (breaking): a repository configuration file may no longer set+ `executable` or a non-empty `extra-dirs`, and its `working-dir` must resolve —+ after following symbolic links — inside the repository root. Each is refused+ with exit 77 naming the setting, or naming both directories. The operator's+ own file and `--set` may still set all three. `executable` turns configuration+ into code execution with the operator's environment and the prompt on standard+ input; `extra-dirs` inside the root adds nothing the working directory does not+ already give, so the only ones a checkout would ask for are outside it.+ (REV-2 F.3.)++### Removed++- `baikai-agent` (breaking): the `BAIKAI_AGENT_EXECUTABLE` environment binding.+ An environment variable is inherited by every child process and is easy to set+ by accident, and naming the program to run is the widest widening there is.+ An operator whose installation is not on `PATH` writes `executable` in their+ own configuration file or passes `--set`.++### Fixed++- `baikai-agent`: a timed-out run now **escalates to `SIGKILL`**. The runner+ interrupts the child's whole process group, then terminates it, then kills it,+ each of the first two stages bounded by the grace period and ended early once+ the leader has been reaped and no member of the group is left. Previously the+ last resort was `terminateProcess` followed by an *unbounded* wait, so a+ coding agent that ignored `SIGTERM` — or a grandchild holding the output pipe+ — hung the run for as long as it chose to live, with the deadline already+ past. Polling the group rather than waiting on the leader alone is also what+ gives a grandchild the same grace the agent gets.++- `baikai-agent`: a timed-out run **reports the output it drained**. `baikai+ agent run` prints it under the same stream discipline a finished run gets, so+ `response=$(baikai agent run job)` under `capture` receives the partial answer+ with `$?` set to 75, and `--json`'s failure envelope carries the same+ `stdout`, `stdoutTruncated`, `stderr` and `stderrTruncated` fields. A drain+ interrupted because something outside the process group still held the pipe+ open keeps its bytes too, reported as truncated.++- `baikai-agent`: the `baikai` command writes its output as **UTF-8 bytes**+ rather than through the locale encoding. Where an unattended run actually+ happens — cron, a systemd unit, a container — the environment says `LANG=C`,+ and on a platform whose locale encoding follows it a single accented character+ in the agent's answer made the write throw after the run had already finished:+ exit 1, answer lost. This mirrors what the prompt read and the prompt write+ have always done.++- `baikai-agent`: the `baikai` executable now links the **threaded runtime**+ (`ghc-options: -threaded` on the `executable baikai` stanza). Without it a+ blocking operating-system call — the `waitpid` inside+ `System.Process.waitForProcess` — stopped every Haskell thread in the+ installed binary, so a job's configured `timeout` could never fire and a+ coding agent that wrote more than one pipe buffer deadlocked against the+ runner's drain threads. Both defects existed only in the shipped executable:+ the test suite was already compiled `-threaded`, so every runner test passed+ under a runtime the binary did not have.++ The suite now proves the runtime the binary ships with rather than its own.+ `baikai-agent/test/BinaryTests.hs` spawns the built executable — cabal builds+ it first and puts it on the suite's `PATH` through+ `build-tool-depends: baikai-agent:baikai` — asserts that `baikai +RTS --info`+ reports `rts_thr`, and runs `baikai agent run` against a stub agent that+ outlives its deadline, requiring exit 75 within seconds and the whole process+ group gone. See+ [docs/adr/0006](docs/adr/0006-a-process-spawning-executable-ships-on-the-threaded-runtime.md).++## [baikai 0.5.0.0] - 2026-08-05++### Added++- `baikai`: new exposed module `Baikai.Agent`, the provider-neutral vocabulary+ for an **unattended coding-agent run** — a run with no terminal and no human,+ which owns its own tool loop, may change files inside directories the caller+ authorized, and returns a process result rather than a `Response`. It defines+ `AgentRunRequest` (with a required `workingDir`), `AgentRunResult`, the+ `AgentCapability` profile (`read-only`, `edit-workspace`, `full-access`),+ `AgentSafety`, the `AgentOutputMode` and `AgentCapturedOutput` output+ discipline, the `AgentCommand` renderer/runner boundary with an explicit+ prompt transport, and the `AgentRenderError` / `AgentRunFailure` taxonomies.++- `baikai`: the operator policy ceiling — `AgentCeiling`,+ `defaultAgentCeiling`, `CeilingViolation`, and the pure `applyAgentCeiling`.+ It returns a request unchanged when it is within the ceiling and reports+ every violation when it is not; it never clamps an over-broad request to the+ permitted value. The default ceiling permits read-only and edit-workspace+ authority and refuses full access and raw provider arguments.++ `Baikai.Agent` itself is vocabulary and pure policy algebra only: it spawns no+ process and renders no command-line flags. Those live in the vendor packages+ and in `baikai-agent`, below. The module is deliberately not re-exported from+ the umbrella `Baikai` module, because its field accessors share names with+ `Baikai.Interactive`, so `import Baikai` continues to compile unchanged.++- `baikai`: new exposed module `Baikai.Evidence`, the vocabulary for+ **verifiable model-call evidence** — a record of what actually crossed the+ boundary to a provider, as opposed to what the process was configured to ask+ for. It defines `ModelCallEvidence` and the `evidenceSchemaVersion` string+ consumers pin against, `Observed` (a deliberate non-`Maybe` for a value the+ provider either did or did not report, with no function that supplies a+ default), `ThinkingTranslation` with its `ThinkingMode` and+ `ThinkingAdjustment` enumerations describing what a requested+ reasoning-effort level actually became on the wire and every clamp, collapse,+ or drop applied on the way, `EndpointIdentity` and `TransportKind`,+ `CallStatus`, and the ascending `EvidenceStrength` scale.++ It also provides the canonical hashing core: `canonicalEncode` gives a JSON+ value exactly one byte representation (object keys sorted, no insignificant+ whitespace, numbers normalised so `1`, `1.0`, `1.00`, and `1e0` all encode as+ `1`, and a hand-written string escaper so an aeson upgrade cannot silently+ invalidate a recorded digest); `commitmentDigest` hashes a full request+ envelope, and `configurationDigest` hashes an allow-list projection+ (`configurationProjection`) that keeps configuration and replaces content with+ structural summaries, so two calls that ask the same model the same way about+ different subjects agree. The two digests are separate on purpose: the first+ binds a record to a particular request, the second is safe to compare across+ runs that legitimately differ in content.++ Nothing constructs a `ModelCallEvidence` from a real call yet, and no existing+ behaviour changed. New dependencies: `cryptohash-sha256` and+ `base16-bytestring`, both single-purpose packages chosen over a full+ cryptographic framework.++- `baikai`: `Options` gains an `evidence` field carrying an optional+ `EvidenceRequest` — the caller's run identifier, retry provenance, and how+ strictly they need evidence. A call whose `evidence` is `Nothing`, which is+ every call that does not opt in, behaves exactly as it did before: no digest+ is computed and no evidence is emitted.++- (Entry added 2026-08-27; the behaviour shipped in 0.5.0.0.) `baikai`: **strict+ evidence mode**. `EvidenceStrictness` is `EvidenceBestEffort` or+ `EvidenceRequired !EvidenceStrength`, and a caller who asks for the second+ gets a call that **refuses to start** — before any request is built or any+ connection opened — when the configuration cannot reach the strength asked+ for: `Baikai.Evidence.Build.checkEvidenceRequirements` compares the+ requirement against what the provider can deliver and against the thinking+ translation, and `completeRequest` / `streamRequest` return an error-shaped+ response or a terminal `EventError` instead of dispatching. The gate is+ pre-dispatch by design; that is the only point at which refusing is still+ free.++- (Entry added 2026-08-27; the behaviour shipped in 0.5.0.0.) `baikai`:+ **sink-failure semantics under strict mode**. `Baikai.Evidence.Build`+ exports `onSinkFailure`, `sinkFailureIsFatal` and `sinkFailureError`: a trace+ sink that throws fails an `EvidenceRequired` caller's call, because a record+ the sink did not confirm written is not a record, while a best-effort caller's+ call succeeds with the failure reported on stderr.++- (Entry added 2026-08-27; the behaviour shipped in 0.5.0.0.) **Breaking.**+ `baikai`: `Baikai.Provider.Registry.ApiProvider` gained a fourth field,+ `describeThinking :: Model -> Options -> ThinkingTranslation`, which the+ pre-dispatch strictness gate calls to learn what a provider would do with the+ caller's reasoning-effort request without sending anything. Every third-party+ provider constructed with the `ApiProvider` constructor stopped compiling.+ This was not recorded at the time; it is the defect that made 0.6.0.0 hide the+ constructor behind `apiProvider` so that the next field addition is a minor+ release.++- `baikai`: model-call evidence is now **produced and emitted**. A caller who+ sets `Options.evidence` gets exactly one `call_evidence` line per call from+ their trace sink, under every way a call can end: success, provider failure, a+ consumer that abandons the stream (status `aborted`, not `failed` — an abort+ is the consumer's doing and reporting it as a provider failure would+ misattribute it), and dispatch that found no registered handler.++ New exposed module `Baikai.Evidence.Build` bridges the vocabulary to the+ `Model` and `Options` records: `minimalEvidence` and `prepareEvidence` build a+ record, `dispatchEnvelope` supplies the request envelope for the paths where+ no adapter ran, `sanitizeEndpoint` reduces a base URL to scheme/host/port/path+ with the query string and any userinfo dropped wholesale, and `onSinkFailure`+ is the hook a future release replaces to make a strict caller's call fail when+ the trace sink does.++ Every record this release produces has `strength` `requested_only` and every+ provider-observed field set to `"unobserved"`. That is not a placeholder: it+ is a truthful record for a transport that has not yet been taught to observe+ anything. Later releases teach each transport to observe more.++ (Correction added 2026-08-27: the two paragraphs above describe the release+ inaccurately and are kept as shipped rather than rewritten. `onSinkFailure`+ did not await a future release — it shipped in 0.5.0.0 together with+ `sinkFailureIsFatal` and `sinkFailureError`, which already fail a strict+ caller's call when the sink throws. And not every 0.5.0.0 record has `strength`+ `requested_only`: the provider entries below describe what each transport+ reports, and the HTTP adapters reach `correlated` and `model_observed`.)++ **A caller who does not opt in pays nothing.** With `Options.evidence` absent+ no digest is computed, no call identifier is generated, no evidence event is+ emitted, and the request envelope is never even forced — the gate lives inside+ the shared builder rather than at each adapter's call site, and the envelope+ parameter is deliberately lazy. Both facts are guarded by tests.++- `baikai`: `TraceEvent` gains a `CallEvidence` constructor, encoded as+ `{"kind":"call_evidence", …}`. A consumer whose pattern match over `TraceEvent`+ is exhaustive must add a branch; one with a wildcard is unaffected. Filter for+ it with `jq 'select(.kind == "call_evidence") | .evidence'`. Note that a trace+ line carries its fields alongside the `kind` discriminator rather than nested+ under a `data` key, and that the evidence record inside spells its own fields+ in snake_case — the two encodings differ deliberately, because an evidence+ record must render an absent field as explicit `null` while a trace line drops+ it to stay small.++- `baikai`: `Baikai.Provider.Cli.Internal` — the module the two subprocess+ providers share — gains the vocabulary for reading what a coding-agent CLI+ reported about its own run. `CodexRunReport` and the new+ `parseCodexJsonlStream :: Stream IO ByteString -> IO CodexRunReport` fold the+ `codex exec --json` event stream into its assistant text, its thread+ identifier, and its token counts, instead of concatenating agent-message text+ and discarding everything else. `ClaudeCliReport` and+ `decodeClaudeCliResult` do the same for `claude -p --output-format json`.+ Every field but the message text is optional, because both tools' event+ schemas have changed across versions and an absent field is a genuine absence+ rather than a parse failure. **Breaking** for anyone calling+ `parseCodexJsonlStream` directly: its result type is no longer `Text`. This is+ an internal module and is documented as outside the PVP guarantee.++- `baikai`: `Baikai.Provider.Cli.Internal` also gains `ExecutableIdentity` and+ `executableIdentity`, which resolve a configured executable name to an+ absolute path and read the tool's own `--version` line. The probe is cached+ per resolved name for the lifetime of the process, because spawning it per+ model call would roughly double the process cost of the cheapest possible+ call, and it is bounded by a five-second timeout so a tool that hangs on+ `--version` cannot wedge a model call. (Corrected 2026-08-27: the entry said+ two seconds; `versionProbeMicros` has always been five.) A probe that fails records the version+ as absent rather than failing the call. It is only ever called from inside+ the evidence branch: a caller who asked for no evidence must not pay for a+ process whose only purpose is to describe a tool they were about to run+ anyway.++- `baikai`: `subprocessStrength` and `cliResponseEnvelope`, also in+ `Baikai.Provider.Cli.Internal`. The former derives a subprocess call's+ evidence strength from what the tool reported and **nothing else** — the exit+ status is deliberately not one of its arguments. The latter spells the+ response-commitment envelope with the same three keys, in the same shapes, as+ the two API transports build by hand, so a verifier holding a response can+ recompute the digest without first knowing which transport served it.++- `baikai`: `Baikai.Agent` gains `AgentRunOutcome` and `agentRunOutcome`. It+ pairs what an unattended run did — the existing+ `Either AgentRunFailure AgentRunResult` — with the evidence the runner built+ for it. The evidence is a sibling of the outcome rather than a field on+ `AgentRunResult` because the run that most needs a record is one that did not+ produce a result: a run killed by its own timeout reports+ `Left (RunTimedOut …)`, so a record hanging off the `Right` would be+ unreachable exactly there.++### Fixed++- `baikai`: a `call_evidence` event is now emitted **before** its call's+ terminal `call_finished` or `call_failed`, rather than after. The+ OpenTelemetry sink ends and removes a call's span on the terminal, so under+ the old order its evidence-attribute branch was unreachable from any real+ call and every backend saw a span with no evidence on it — nothing failed,+ the attributes were simply never there. No consumer can have depended on the+ old order, because no consumer has ever seen a `call_evidence` line.++- `baikai`: the `ThinkingFormatOpenAI` Haddock in `Baikai.Compat` listed the+ native `reasoning_effort` vocabulary as `minimal | low | medium | high`, which+ predates `xhigh` and `max`. It now lists all six and states that this shape+ alone sends the canonical baikai level verbatim while the other six clamp+ through `compatibleEffort`. No behaviour changed: the native path's exclusion+ from that clamp is deliberate and is guarded by two named tests in+ `baikai-openai/test/ShapeSpec.hs`. A reader who consulted the comment to+ decide whether `xhigh` was safe to use against OpenAI has until now been told+ something untrue.++### Changed++- **Breaking:** `baikai`: `TerminalPayload` gains an `evidence` field and the two+ terminal smart constructors take it as their new first argument:+ `doneTerminal :: Maybe ModelCallEvidence -> Maybe Text -> StopReason -> Message -> TerminalPayload`+ and `errorTerminal` likewise. `Response` gains the same field. A custom+ provider implementation must pass `Nothing` (or a record it builds through+ `Baikai.Evidence.Build`); a custom `Response` built with the record+ constructor must add `evidence = Nothing`. Code that only pattern-matches on+ these types is unaffected.++- **Breaking:** `baikai`: `CallFinished` gains `cachedInputTokens`,+ `cacheWriteTokens`, `reasoningTokens`, and `totalTokens`. The trace path used+ to drop counts that `Baikai.Cost.Log.CallLogEntry` kept from the same `Usage`+ value, which made the cost log strictly more faithful than the trace.++- **Breaking:** `baikai`: a computed cost of **zero is now reported as zero**+ rather than suppressed, in `CallFinished` and at all three `CallLogEntry`+ construction sites. Previously `usd` was omitted whenever the cost came out at+ zero, so "this call was free" and "baikai could not price this call" were+ indistinguishable — and the subscription-based CLI providers always price at+ zero, so that was the common case rather than a corner. **A cost dashboard+ that treated an absent `usd` as "unpriced" will now count those calls as+ costing zero.** That is the correct reading, but it changes what such a+ dashboard shows.++- **Breaking:** `baikai`: `FromJSON TraceEvent` is written out by hand instead of+ derived. The three pre-existing kinds decode exactly as before; a+ `call_evidence` line fails to parse with a message saying to read it as a+ plain `Data.Aeson.Value`. `ModelCallEvidence` has no `FromJSON` on purpose —+ it embeds a `Cost` whose exact `Rational` amounts encode through an+ approximating `Scientific`, so a decoder would return a different value than+ was encoded — and manufacturing that fidelity would be the precise failure+ this vocabulary exists to eliminate.++- `baikai`: `Baikai.Trace.Sink.renderHuman` renders a `CallEvidence` event as a+ single `EVIDENCE run=… call=… strength=…` line rather than the whole record. A+ human-readable sink is for watching calls go by; the full record is meant to+ be read out of `fileSink` output by a machine.++- `baikai`: call identifiers on the trace path are now globally unique.+ `Baikai.Evidence.newCallId` produces 32 lowercase hexadecimal characters+ carrying 128 bits — 48 bits of Unix time in milliseconds, 48 bits of a+ per-process random seed drawn once from `/dev/urandom`, and a 32-bit counter.+ The previous generator combined the process-start *second* with a+ process-local counter into 16 characters, so two processes started within the+ same second emitted identical identifier sequences; its own documentation+ claimed only per-process uniqueness. Identifiers still sort chronologically+ and are still not secrets.++ `Baikai.Trace.newEventId` keeps its name and signature, delegates to+ `newCallId`, and is now deprecated. Anything that pinned the 16-character+ width — a log parser, a fixture, a column type — must widen to 32.++- `baikai`: `renderCeilingViolation` no longer prints the raw provider arguments+ a `ProviderArgsForbidden` violation carries. It reports how many were+ requested and states that their values are not shown. Raw provider arguments+ are the one part of a job description that can hold a credential — the+ configuration layer classifies the setting secret for that reason — and a+ refusal message that quoted them defeated the classification. The constructor+ keeps its `[Text]` payload so a programmatic caller can still inspect it.++## [baikai-claude 0.5.0.0] - 2026-08-05++### Added++- `baikai-claude`: new exposed module `Baikai.Provider.Claude.Agent` with+ `ClaudeAgentConfig`, `defaultClaudeAgentConfig`, and `claudeAgentCommand`, a+ pure renderer from an unattended `AgentRunRequest` to the `claude` argument+ vector. It maps the capability profile onto `--permission-mode`+ (`plan` / `acceptEdits` / `bypassPermissions`), joins a tool allow-list into+ one `--allowedTools` argument, repeats `--add-dir` per extra directory, always+ emits `-p`, and emits `--no-session-persistence` unless `persistSession` is+ set. The prompt travels on standard input and appears nowhere in the argument+ vector. A request naming a different provider is refused with+ `ProviderMismatch`. Nothing is spawned.++- `baikai-claude`: the Anthropic Messages provider now fills in the evidence+ record it previously left blank. It records the model **Anthropic reported+ running** (read from the `message_start` event, which the adapter already+ decoded for the response id and then discarded), Anthropic's `request-id`+ correlation header, the response id, the token counts Anthropic actually+ reported, and a commitment digest over the assembled response. A field the+ provider did not report stays `"unobserved"` and is never backfilled from the+ request — in particular, a stream that fails before `message_start` reports no+ observed model at all. `strength` is `model_observed` when both the model and a+ correlation identifier arrived, `correlated` when only the identifier did, and+ `requested_only` otherwise; a 2xx status never raises it, because a 200 means+ the request was accepted, not that any particular model ran.+ `fully_observed` is unreachable on this transport, since Anthropic does not+ echo the thinking configuration it applied.++- `baikai-claude`: an evidence record's `thinking` field now describes what the+ caller's reasoning-effort preference actually became on the wire, including+ three downgrades that were previously invisible everywhere in baikai's output:+ asking for thinking on a model that does not advertise `reasoning`+ (`thinking_dropped_unsupported_model`); asking for a level whose token budget+ does not fit under the resolved output-token ceiling+ (`thinking_dropped_budget_exceeded`, carrying both colliding numbers), which is+ reachable by lowering `maxTokens` alone; and asking for `high` on an+ adaptive-thinking model, which sends no effort field and so is+ wire-indistinguishable from taking Anthropic's default depth+ (`effort_omitted`). `minimal` on an adaptive model reports `effort_clamped`,+ because Anthropic's adaptive vocabulary has no `minimal`.++- `baikai-claude`: new exports from `Baikai.Provider.Claude.Sse` —+ `ResponseMetadata` and `capturedHeaderNames` — and from+ `Baikai.Provider.Claude.Api` — `claudeMessagesStreamWith`, `SseDriver`, and+ `anthropicStrength`. Response-header capture is an **allow-list**+ (`request-id`, `x-request-id`, `cf-ray`, in that preference order), not a+ denylist, so a header a future gateway adds is not recorded by default.++- `baikai-claude` and `baikai-openai`: both subprocess providers now fill in the+ evidence record they previously left blank, and both export the translation+ function that describes it — `claudeCliThinking` and `codexCliThinking`. They+ record the session or thread identifier the tool reported, the token counts it+ reported, the model it named when it names one, the resolved executable path+ in place of an endpoint URL, the tool's own `--version` string as the+ implementation version (for this transport the tool *is* the implementation),+ a request commitment over the rendered argument vector, and a response+ commitment over the assembled answer.++ **A zero exit status never raises the strength.** A coding-agent CLI that+ exits zero has demonstrated that it ran and did not crash; it has not stated+ which model served the request. Subprocess calls almost always exit zero, so+ encoding that as corroboration would make the weakest evidence in the system+ look like the strongest. `strength` is `model_observed` only when the tool+ named both an identifier and a model, `correlated` when it named only an+ identifier, and `requested_only` otherwise.++ The two transports differ in how far they can get. `claude` names the model+ that consumed tokens in its result event's `modelUsage` map, complete with a+ context-window variant marker such as `[1m]`, so a Claude CLI run can reach+ `model_observed`. `codex-cli 0.146.0` names no model anywhere in its event+ stream, so **no** Codex CLI run can exceed `correlated` — backfilling the+ `--model` flag baikai passed would report the request as an observation.++- `baikai-claude`: an evidence record's `thinking` field now describes what a+ reasoning-effort request became on the `claude` command line: mode `flag`,+ wire field `--effort`, and an `effort_clamped` adjustment recording the+ `minimal` → `low` collapse, because the tool's `--effort` flag has no+ `minimal`. A caller asking for `minimal` and a caller asking for `low` produce+ byte-identical argument vectors — and therefore identical request commitment+ digests — so the translation is the only place that difference survives.++- **Breaking:** `baikai-claude` and `baikai-openai`: `claudeAgentCommand` and+ `codexAgentCommand` return `(AgentCommand, ThinkingTranslation)` rather than+ `AgentCommand`. The runner deliberately imports no vendor renderer, so it+ cannot derive the translation and has to be handed it. A caller that only+ wants the command writes `fmap fst`. Both modules also export the translation+ function alone — `claudeAgentThinking` and `codexAgentThinking` — for asking+ what a level would become without rendering anything.++### Fixed++- **Loud:** `baikai-claude` and `baikai-openai`: both subprocess providers+ hardcoded `usage = zeroUsage` on every call, so a cost dashboard saw every+ `claude -p` and `codex exec` call as consuming no tokens and costing nothing.+ Both tools report their own token counts and baikai now carries them through,+ normalized into the disjoint `Usage` convention: `claude`'s counts are+ Anthropic-shaped and already disjoint, while `codex` reports OpenAI-style+ inclusive prompt counts, so its cached tokens are subtracted out of+ `inputTokens`. `claude` additionally reports a `total_cost_usd`, which now+ populates `Usage.cost` exactly rather than being reported as zero.++ **A dashboard that read these calls as free will now see real tokens and, for+ `claude`, a real cost.** That is the correction, not a regression — but it+ changes what existing reports show, and totals over historical data will not+ match totals over new data.++- `baikai-claude`: `Response.responseId` was always `Nothing` on the `claude -p`+ transport even though `ClaudeCliResult` decoded the tool's `session_id` one+ screen earlier and then dropped it. It now carries that identifier, on both+ the successful and the failed terminal. `baikai-openai`: the same for+ `codex exec`, whose thread identifier was filtered out of the event stream+ along with everything that was not an `agent_message`. These are the handles+ each vendor's support tooling looks a run up by.++### Changed++- **Breaking:** `baikai-claude`: `Baikai.Provider.Claude.Sse`'s four streaming+ entry points — `claudeSseStream`, `claudeSseStreamValue`,+ `claudeSseStreamValueWithHeaders`, and `sseFromResponse` — take a new+ `ResponseMetadata -> IO ()` callback immediately before the existing per-event+ callback. It fires exactly once, before the first event, on both the success+ and the non-2xx path. Pass `(\_ -> pure ())` to keep the previous behaviour.+ The callback is separate rather than a widening of the per-event one because+ the per-event callback runs once per SSE frame and response-level data does not+ belong on that path.++- **Breaking:** `baikai-claude`: `Baikai.Provider.Claude.Internal.Request`'s+ `mapRequest` now returns+ `Either Text (Messages.CreateMessage, ThinkingTranslation)` and+ `computeThinking` returns `(ThinkingPlan, ThinkingTranslation)`. Take `fst` to+ keep the previous value. This module is exposed for provider tests and+ debugging and its header states it is not covered by PVP compatibility+ guarantees, but the change is recorded here because that is not a licence to+ break a consumer silently.++- **Breaking:** `baikai-claude`: `claudeInteractiveCommand` now returns+ `Either AgentRenderError (FilePath, [String])` and `launchClaudeInteractive`+ returns `IO (Either AgentRenderError InteractiveLaunchResult)`. A request+ whose `safety` is a `CodexSandbox` policy — which Claude Code cannot express+ — is refused with `SafetyNotExpressible AgentClaude`, naming the rejected+ sandbox mode and approval policy and suggesting `ClaudeAllowedTools` or+ `DefaultSafety`. Previously the policy was silently discarded and an+ **unrestricted** Claude session was started and reported as a success. A+ `Left` means no process was started; a `Right` with a non-zero exit code+ means the session ran and exited non-zero. `DefaultSafety` and an empty+ `ClaudeAllowedTools` list still render no safety flag and are never refused,+ and no previously rendered argument vector changed. Callers must handle the+ refusal branch.++## [baikai-openai 0.5.0.0] - 2026-08-05++### Added++- `baikai-openai`: new exposed module `Baikai.Provider.OpenAI.Agent` with+ `CodexAgentConfig`, `defaultCodexAgentConfig`, and `codexAgentCommand`, the+ same renderer for `codex exec`. It maps the capability profile onto+ `--sandbox` (`read-only` / `workspace-write` / `danger-full-access`), emits+ `--cd` for the working root, and defaults `--skip-git-repo-check` and+ `--ephemeral` on. A request carrying a tool allow-list is **refused** with+ `UnsupportedToolRestriction`, because `codex exec` has no such flag and running+ it with unrestricted tools would grant more authority than the caller asked+ for. Nothing is spawned.++- `baikai-openai`: an evidence record's `thinking` field now describes what the+ caller's reasoning-effort preference became on the wire for the specific host+ the call went to, across **all seven** OpenAI-compatible wire shapes. The+ OpenAI-native shape sends the canonical level verbatim and records no+ adjustment, because it expresses every level exactly. The four shapes that+ carry an effort word for a non-native host record `effort_clamped` whenever+ the word differs from the canonical name — `minimal` becomes `low`, and both+ `xhigh` and `max` become `high`. Z.ai and Qwen accept a bare+ `enable_thinking: true` with no depth, so **every** level records+ `effort_collapsed_to_toggle`: a caller asking for `max` and a caller asking+ for `low` produce byte-identical requests there, and only the evidence record+ can tell them apart. A host with no reasoning controls records+ `thinking_dropped_unsupported_host` where the option previously vanished with+ no trace. A forty-two-row table test pins the translation and the shaped+ request body for every shape at every level.++- `baikai-openai`: the Chat Completions provider now fills in the evidence record+ it previously left blank. It records the model **the host reported running**+ (read from the first streamed chunk carrying a top-level `model` field and+ never overwritten by a later one), the host's `x-request-id` correlation+ header, the response id, the token counts the host actually reported, and a+ commitment digest over the assembled response. A field the host did not report+ stays `"unobserved"` and is never backfilled from the request — in particular,+ a call that fails before any chunk arrives reports no observed model at all.+ `strength` is `model_observed` when both the model and a correlation+ identifier arrived, `correlated` when only the identifier did, and+ `requested_only` otherwise; a 2xx status never raises it, because a 200 means+ the request was accepted, not that any particular model ran.+ `fully_observed` is unreachable on this transport, since no host in this+ ecosystem echoes the reasoning configuration it applied.++- `baikai-openai`: new exports from `Baikai.Provider.OpenAI.Sse` —+ `ResponseMetadata` and `capturedHeaderNames` — and from+ `Baikai.Provider.OpenAI.Api` — `openaiChatStreamWith` and `SseDriver`.+ Response-header capture is an **allow-list** (`x-request-id`, `request-id`,+ `x-amzn-requestid`, `x-ms-request-id`, `cf-ray`, in that preference order),+ not a denylist, so a header a future gateway adds is not recorded by default.+ The list is longer than the Anthropic one because this transport speaks to an+ open-ended set of hosts and the gateways commonly in front of them.++- `baikai-openai`: the same field for `codex exec`: mode `flag`, wire field+ `model_reasoning_effort`, and **no** adjustments at any level. Codex is the+ only transport in baikai that expresses all six canonical levels exactly, and+ a test asserts each one reaches the command line verbatim.++### Fixed++- `baikai-openai`: `Response.responseId` was always `Nothing` on the Chat+ Completions transport, although every compatible host sends a top-level `id`+ on every streamed chunk. It now carries the identifier the host reported, on+ both the successful and the failed terminal.++### Changed++- **Breaking:** `baikai-openai`: `Baikai.Provider.OpenAI.Sse`'s four streaming+ entry points — `openaiSseStream`, `openaiSseStreamValue`,+ `openaiSseStreamValueWithHeaders`, and `sseFromResponse` — take a new+ `ResponseMetadata -> IO ()` callback immediately before the existing per-chunk+ callback. It fires exactly once, before the first chunk, on both the success+ and the non-2xx path — a failed call's correlation identifier is if anything+ more valuable than a successful one's. Pass `(\_ -> pure ())` to keep the+ previous behaviour. The callback is separate rather than a widening of the+ per-chunk one because that one runs once per SSE frame and response-level data+ does not belong on that path.++- **Breaking:** `baikai-openai`: `Baikai.Provider.OpenAI.Api`'s `RawChunk` gains+ `model` and `responseId` fields, both `Maybe Text`. Code that pattern-matches+ on `RawChunk` is unaffected; code that constructs one with record syntax must+ add them.++- **Breaking:** `baikai-openai`: `Baikai.Provider.OpenAI.Shape`'s+ `shapeRequestBody`, `streamRequestBody`, and `injectThinkingShape` now return+ `(Aeson.Value, ThinkingTranslation)` instead of a bare body. Take `fst` to+ keep the previous value. The description has to travel out of the shaping step+ because nothing downstream can recompute it: it depends on the host's+ `ThinkingFormat`, which only the compat lookup knows. **No request body+ changed** — every one of the seven shapes puts exactly the same bytes on the+ wire as before.++- **Breaking:** `baikai-openai`: `codexInteractiveCommand` now returns+ `Either AgentRenderError (FilePath, [String])` and `launchCodexInteractive`+ returns `IO (Either AgentRenderError InteractiveLaunchResult)`. A request+ whose `safety` is a non-empty `ClaudeAllowedTools` list — which `codex` has+ no flag for — is refused with `SafetyNotExpressible AgentCodex`, quoting the+ rejected tools and suggesting `CodexSandbox` or `DefaultSafety`. Previously+ the allow-list was silently discarded and Codex was started with its default+ sandbox. The same `Left`/`Right` reading applies, `DefaultSafety` and an+ empty allow-list are never refused, and no previously rendered argument+ vector changed. Callers must handle the refusal branch.++ Both changes make the interactive surface honor the same contract as the new+ unattended surface: a safety policy the chosen provider cannot express fails+ visibly instead of silently becoming a weaker policy. Downstream consumers+ must adapt before upgrading; the known one is `shinzui/seihou`, whose+ `Seihou.CLI.AgentLaunchExec` module builds interactive launch requests.++## [baikai-trace-otel 0.3.0.3] - 2026-08-05++### Added++- `baikai-trace-otel`: the sink attaches an evidence record's salient fields to+ the open span as flat attributes (`baikai.evidence.run_id`,+ `baikai.evidence.call_id`, `baikai.evidence.strength`, the two digests, and+ `gen_ai.response.model` only when the provider actually reported one) rather+ than serialising the record into one blob. A `CallEvidence` event neither+ opens nor closes a span.++### Changed++- `baikai-trace-otel`: widened its `baikai` bound to admit `0.5`. No API change.++## [baikai-effectful 0.3.0.3] - 2026-08-05++### Changed++- Widened its `baikai` bound to admit `0.5`. No API change; the package's+ own surface is untouched.++## [baikai-kit 0.1.0.4] - 2026-08-05++### Changed++- Widened its `baikai` bound to admit `0.5`. No API change; the package's+ own surface is untouched.++## [baikai-agent 0.1.0.0] - 2026-08-05++### Added++- `baikai-agent`: **new package** (`0.1.0.0`) holding the unattended+ coding-agent runner. `Baikai.Agent.Run.runAgentCommand` takes an+ `AgentRunRequest` and an already-rendered `AgentCommand` and spawns the tool+ with no terminal and no human present. It delivers the prompt on standard+ input and closes the handle, drains standard output and standard error+ concurrently so a chatty agent cannot deadlock on a full pipe, retains at most+ `outputLimit` bytes per stream while reading and discarding the excess, and+ honors the three output disciplines. Preconditions run before any spawn: a+ missing working directory is `WorkingDirMissing` and unset or empty declared+ variables are `MissingEnvironment`, listing all of them at once. On timeout+ the child's whole process group is interrupted, given a grace period, and then+ terminated, so the agent's own child processes go with it; the failure reports+ the configured limit. A non-zero exit code is a successful run carrying that+ code, not a failure. The runner consumes an already-rendered `AgentCommand`+ and never imports a vendor renderer, so it is exercised entirely with+ hand-written argument vectors. Its POSIX-signal escalation is conditional on a+ non-Windows build.++- `baikai-agent`: new exposed module `Baikai.Agent.Config`, the layered+ configuration layer. `resolveAgentJob` resolves one named job across five+ layers — built-in defaults, the operator file, the repository file, the+ environment, then command-line overrides, later layers winning — and returns+ the resolved `AgentJob` together with a report attributing every value to the+ file, line, and column it came from. `agentJobRequest` converts a job into an+ `AgentRunRequest`, taking the prompt at call time. `listAgentJobs` enumerates+ configured job names, sorted, each attributed to the highest-precedence scope+ defining it. `defaultAgentConfigPaths` locates+ `$XDG_CONFIG_HOME/baikai/agents.kdl` (or `$HOME/.config/baikai/agents.kdl`)+ and `./.baikai/agents.kdl`, with no upward search through parent directories.++ The **policy ceiling** is loaded by a separate function, `loadAgentCeiling`,+ against a separate source list containing the operator file and nothing else:+ no repository file, environment variable, or command-line override can raise+ it. `applyCeilingToJob` refuses an over-broad request with `CeilingRejected`+ rather than clamping it. With no operator file the ceiling is+ `defaultAgentCeiling`. `safety.provider-args` is classified secret and renders+ as `<redacted>` in any report or structured error.++ New dependencies: `settei`, `settei-env`, `settei-kdl`, and+ `settei-optparse-applicative` (all `^>=0.2`, published on Hackage at+ `0.2.0.0`), plus `containers` and `filepath`. `settei-formats` is deliberately+ excluded, because it bundles Dhall loading and repository configuration is+ untrusted input here.++- `baikai-agent`: the **`baikai` executable**, with the `agent run`,+ `agent show`, and `agent list` commands, and the `Baikai.Agent.Cli` module+ that implements them. A shell script now invokes one stable command, supplies+ a prompt on standard input, and selects Claude Code or Codex entirely through+ configuration.++ `agent run` resolves the named job, caps it against the operator ceiling,+ renders it through the vendor renderer for its provider, and spawns it. The+ agent's own exit code passes through unchanged; Baikai's own failures use 64+ and above following the `sysexits` convention — 64 for a usage error or an+ empty prompt, 69 when the executable could not be started, 70 for malformed+ output, 75 for a timeout, 77 for a policy refusal, and 78 for a configuration+ problem. The prompt comes from `--prompt-stdin`, `--prompt-file`, or+ `--prompt`, which are mutually exclusive, and is decoded as UTF-8 explicitly+ rather than through the handle's locale encoding.++ `agent show` performs the whole pipeline except spawning and prints each+ resolved value with the file, line, and column it came from, the policy+ ceiling in force and where it was read, and the exact argument vector that+ would be spawned — with `<redacted>` in place of any raw provider argument. A+ job whose policy is refused prints its configuration first and then the+ refusal. `agent list` enumerates configured jobs and the scope each came from.++ Every Baikai diagnostic goes to standard error. The agent's own output follows+ the job's output mode, so `response=$(baikai agent run job)` yields the+ agent's answer alone for a capturing job. `--set KEY=VALUE` overrides one+ setting of the selected job through `settei`'s own command-line source, so an+ override is attributed with the same fidelity as a file. `--json` emits+ exactly one JSON object per command.++ New dependencies for `baikai-agent`: `baikai-claude`, `baikai-openai`, and+ `optparse-applicative`. The provider packages are needed only so that+ `renderJobCommand`, the single provider dispatch point in the codebase, can+ reach both renderers. This is the first dependency in the workspace from+ `baikai-agent` onto the provider packages, so `baikai-agent` now publishes+ after all three of `baikai`, `baikai-claude`, and `baikai-openai`.++ The user guide `docs/user/unattended-agent-runs.md` documents the whole+ surface: the three commands with their flags, exit codes, and stream+ discipline; the KDL job format and layer precedence; the operator ceiling and+ redaction; the capability mapping tables for both tools; and a before-and-after+ migration of a script that embeds provider flags today.+ `docs/user/cli-providers.md` and `docs/user/interactive-launches.md` link to+ it, and the capability mapping tables moved there from the latter.++- `baikai-agent`: **an unattended coding-agent run now produces model-call+ evidence.** This surface previously had no observability of any kind: no trace+ sink, no `Response`, no usage, no identifiers. An operator could show that a+ process started, exited, and took some time; they could not show which model+ ran, which reasoning effort was applied, or which agent session the run+ corresponds to in the vendor's records.++ A record carries the run and call identifiers, the resolved executable and its+ own reported version, digests over the request, the requested model and what+ the reasoning-effort request became on the command line, whatever the tool+ reported about itself, the outcome, and an honest strength.++ **A zero exit status never raises the strength.** On this surface that rule+ matters more than anywhere else, because almost every unattended run exits+ zero. A coding agent that exits zero has demonstrated that it ran, not which+ model served it.++ Two things gate what a record can prove, and neither is the default. The job+ must **capture** output — under `inherit` the agent's bytes went to the+ operator's terminal and baikai never held them — and the tool must be+ configured to print a structured format, which means `--output-format json`+ for `claude` or `--json` for `codex exec` through the job's `provider-args`.+ Without both, the tool's session identifier, model, and token counts are+ genuinely unavailable and the record says `"unobserved"` rather than inferring+ anything. A timed-out run records `aborted`; a run that never started records+ nothing at all.++- **Breaking:** `baikai-agent`: `Baikai.Agent.Run.runAgentCommand` takes two new+ leading arguments and returns the new outcome type:+ `Maybe EvidenceRequest -> ThinkingTranslation -> AgentRunRequest -> AgentCommand -> IO AgentRunOutcome`.+ A caller who wants the previous behaviour passes `Nothing` and+ `Baikai.Evidence.noThinkingRequested` and reads the `outcome` field; that path+ is byte-for-byte what it was, and costs what it cost — no digest is computed,+ no call identifier is generated, and the tool is not invoked a second time to+ read its version.++- `baikai-agent`: `baikai agent run` gains `--evidence-file PATH` and+ `--run-id TEXT`. Supplying neither leaves the run on the pre-existing path at+ the pre-existing cost; supplying either turns recording on, with the job's own+ name standing in as the run identifier when only a destination is given. The+ file is written atomically — a staging file beside the destination, then a+ rename — so a reader polling the path never sees a half-written object, and it+ is never appended to. A failed write is reported on standard error and never+ changes the exit code, because the agent's own status is what a calling script+ branches on. `docs/user/unattended-agent-runs.md` documents both options and,+ more importantly, what the record does and does not prove.++- `baikai-agent`: `baikai agent run` gains `--require-evidence STRENGTH`, taking+ `requested_only`, `correlated`, `model_observed`, or `fully_observed` — the+ same words a record's `strength` field spells, so what one record showed can+ be passed back as the next run's requirement. A job whose configuration cannot+ produce evidence of at least that strength is refused before anything is+ spawned, exiting 77 — the code a ceiling violation and an inexpressible safety+ policy already use, so a script branching on 77 needs no new case.++## [baikai-claude 0.4.0.1] - 2026-07-30++### Fixed++- Widened the `crypton` bound from `^>=1.0` to `>=1.0 && <1.2` so consumers can+ build `baikai-claude` alongside packages that require `crypton` 1.1.x (for+ example `pg-migrate-1.1.0.0`), which previously had no solvable build plan.+ The only `crypton` use is `Crypto.Hash` (`Digest`, `SHA256`) in+ `Baikai.Provider.Claude.Transport`, whose API is identical across the 1.0/1.1+ boundary. No API change.++## [baikai 0.4.1.0] - 2026-07-20++### Changed++- Version bump only; no library API or code changes. Released so the umbrella+ release tag `baikai-0.4.1.0` names a fresh core version alongside the breaking+ `baikai-claude` / `baikai-openai` 0.4.0.0 releases, matching the tag+ convention downstream consumers pin against.++## [baikai-claude 0.4.0.0] - 2026-07-20++### Changed++- **Breaking:** `claudeCliCommand` now takes the `Options` record and forwards+ `Options.thinking` to batch `claude -p` as `--effort <level>` (`minimal`+ collapses to `low`, matching the interactive launcher and the claude CLI's+ lack of a `minimal` value). `thinking = Nothing` emits no effort flag, keeping+ existing argv byte-for-byte. The added parameter is a PVP-major signature+ change.++## [baikai-openai 0.4.0.0] - 2026-07-20++### Changed++- **Breaking:** `codexCliCommand` now takes the `Options` record and forwards+ `Options.thinking` to `codex exec` as `-c model_reasoning_effort=<level>` for+ all six effort levels. `thinking = Nothing` emits no override, keeping+ existing argv byte-for-byte. The added parameter is a PVP-major signature+ change.++## [baikai 0.4.0.0] - 2026-07-20++### Added++- Added `ThinkingXHigh` and `ThinkingMax` to the exported `ThinkingLevel`+ vocabulary and added a defaulted `InteractiveLaunchRequest.effort` field.+ Extending the closed sum type is a PVP-major API change for downstream+ exhaustive matches.++## [baikai-claude 0.3.0.2] - 2026-07-20++### Added++- Added `--effort` rendering to interactive Claude Code launches and preserved+ `xhigh` / `max` on native adaptive Anthropic API requests, with larger fixed+ budgets for manual-thinking models.++### Changed++- Bumped the internal `baikai` dependency bound to `^>=0.4.0` for the+ baikai 0.4.0.0 release.++## [baikai-openai 0.3.0.2] - 2026-07-20++### Added++- Added `model_reasoning_effort` overrides to interactive Codex launches and+ preserved `xhigh` / `max` in native OpenAI request JSON; non-native+ OpenAI-compatible request shapes continue to clamp them to `high`.++### Changed++- Bumped the internal `baikai` dependency bound to `^>=0.4.0` for the+ baikai 0.4.0.0 release.++## [baikai-trace-otel 0.3.0.2] - 2026-07-20++### Changed++- Bumped the internal `baikai` dependency bound to `^>=0.4.0` for the+ baikai 0.4.0.0 release. No API changes.++## [baikai-effectful 0.3.0.2] - 2026-07-20++### Changed++- Bumped the internal `baikai` dependency bound to `^>=0.4.0` for the+ baikai 0.4.0.0 release. No API changes.++## [baikai-kit 0.1.0.3] - 2026-07-20++### Changed++- Bumped the internal `baikai` dependency bound to `^>=0.4.0` for the+ baikai 0.4.0.0 release. No API changes.++## [baikai 0.3.1.0] - 2026-07-15++### Added++- Added `claude-sonnet-5` to the Anthropic model catalog (1M context window,+ 128k max output, `tool_call` + reasoning).+- Added the `gpt-5.6` family — `gpt-5.6`, `gpt-5.6-luna`, `gpt-5.6-sol`, and+ `gpt-5.6-terra` — to the OpenAI model catalog (chat-completions with+ `tool_call` support).++### Changed++- Corrected `claude-sonnet-4-5` context window to 1M tokens and+ `claude-sonnet-4-6` max output to 128k tokens in the catalog.+- Added PVP-compliant upper bounds to all previously-unbounded library and+ executable dependencies.++## [baikai-claude 0.3.0.1] - 2026-07-15++### Changed++- Added PVP-compliant upper bounds to all previously-unbounded library and+ executable dependencies.++## [baikai-openai 0.3.0.1] - 2026-07-15++### Changed++- Added PVP-compliant upper bounds to all previously-unbounded library and+ executable dependencies.++## [baikai-trace-otel 0.3.0.1] - 2026-07-15++### Changed++- Added PVP-compliant upper bounds to all previously-unbounded library and+ executable dependencies.++## [baikai-effectful 0.3.0.1] - 2026-07-15++### Changed++- Added PVP-compliant upper bounds to all previously-unbounded library and+ executable dependencies.++## [baikai-kit 0.1.0.2] - 2026-07-15++### Changed++- Added PVP-compliant upper bounds to all previously-unbounded library and+ executable dependencies.++## [baikai 0.3.0.0] - 2026-07-03++### Added++- Added the documented record-update bases `emptyOptions`, `emptyContext`,+ `emptyModel`, `emptyResponse`, `emptyTool`, `emptyTextContent`,+ `emptyThinkingContent`, `emptyToolCall`, `emptyImageContent`,+ `emptyEmbeddingModel`, plus zero-valued bases `zeroUsage`, `zeroCost`,+ `zeroCostBreakdown`, and `zeroModelCost`.+- Added `firstEmbedding`, a total accessor for OpenAI-compatible embedding+ responses.+- Added `responseError`, `errorResponse`, `httpError`, and+ `parseRetryAfterSeconds` for the in-band error contract.++### Changed++- **Breaking:** Constructors for evolvable records are no longer exported:+ `Options`, `Context`, `Model`, `OpenAICompletionsCompat`,+ `AnthropicMessagesCompat`, and `InteractiveLaunchRequest` are built from+ exported base values plus record updates.+- **Breaking:** The `_X` base values are deprecated in favor of the new+ `empty*` and `zero*` names; the aliases remain for this release.+- **Breaking:** Removed `unModel`; use `mkModel` or `emptyModel` record+ updates.+- **Breaking:** Renamed `InteractiveLaunchRequest.model` to `modelId`.+- **Breaking:** `Response.latencyMs` and trace event `latencyMs` fields are+ now `Int`.+- **Breaking:** `completeRequest` / `completeRequestWith` no longer throw+ `BaikaiError` for unregistered API tags; they return an error-shaped+ `Response`.+- **Breaking:** CLI providers now report subprocess/decode/provider failures+ in-band as error-shaped `Response`s.+- **Breaking:** `errorTerminal` now requires a `BaikaiError`, enforcing+ structured error details for `EventError` construction sites.+- Documented that `Baikai.Prelude` is a convenience module outside the PVP+ stability contract and that `.Internal` modules have no compatibility+ guarantees.++### Fixed++- Empty embedding `data` arrays now produce a typed `decodeError` instead of+ crashing on an empty vector.+- The model-fetch JSON renderer now delegates string escaping to aeson.+- The model generator now fails on sanitized Haskell identifier collisions+ instead of rendering duplicate bindings.+- Live HTTP status, `Retry-After`, and network-failure classification now+ works on both API providers.+- `content_filter` / Anthropic refusals terminate as classified `EventError`+ terminals, and `liftCompleteToStream` preserves error-shaped responses.++## [baikai-claude 0.3.0.0] - 2026-07-03++### Changed++- **Breaking:** `Baikai.Provider.Claude.ErrorClass` moved to+ `Baikai.Provider.Claude.Internal.ErrorClass`.+- **Breaking:** `mapRequest` and pure request-shaping helpers moved from+ `Baikai.Provider.Claude.Api` to+ `Baikai.Provider.Claude.Internal.Request`.+- **Breaking:** `ClaudeCliConfig` and `ClaudeInteractiveConfig` constructors+ are no longer exported; start from their default config values and update+ fields.+- **Breaking:** CLI and interactive `extraArgs` fields are now `[Text]`.++## [baikai-openai 0.3.0.0] - 2026-07-03++### Changed++- **Breaking:** `Baikai.Provider.OpenAI.ErrorClass` moved to+ `Baikai.Provider.OpenAI.Internal.ErrorClass`.+- **Breaking:** `mapRequest` and pure request-shaping helpers moved from+ `Baikai.Provider.OpenAI.Api` to+ `Baikai.Provider.OpenAI.Internal.Request`.+- **Breaking:** `CodexCliConfig` and `CodexInteractiveConfig` constructors are+ no longer exported; start from their default config values and update fields.+- **Breaking:** CLI and interactive `extraArgs` fields are now `[Text]`.++## [baikai-trace-otel 0.3.0.0] - 2026-07-03++### Changed++- Updated the `baikai` dependency bound to `^>=0.3.0`.+- Adjusted to the core trace event `latencyMs :: Int` type.++## [baikai-effectful 0.3.0.0] - 2026-07-03++### Changed++- Updated the `baikai` dependency bound to `^>=0.3.0`.++## [baikai-kit 0.1.0.1] - 2026-07-03++### Changed++- Updated the `baikai` dependency bound to `^>=0.3.0`.++## [baikai 0.2.0.0] - 2026-06-21++### Added++- `Usage`, `Cost`, and `CostBreakdown` now have `Semigroup`/`Monoid`+ instances that add field-by-field, plus `sumUsage :: Foldable f => f+ Usage -> Usage`, so callers can total per-call usage and cost.+ `reasoningTokens` combines as presence-wins (`Nothing` only when both+ operands are `Nothing`).+- A categorised error model: `BaikaiError` is now a record carrying an+ `ErrorCategory` (`AuthError`, `RateLimited`, `ContextOverflow`,+ `InvalidRequest`, `TransientError`, `DecodeFailure`, `ProcessFailure`,+ `ProviderUnavailable`, `OtherError`), an optional HTTP `httpStatus`, a+ `retryAfterSeconds` hint, and a subprocess `exitCode`. New smart+ constructors (`providerError`, `invalidRequest`, `decodeError`,+ `processError`, `rateLimited`, `authError`, `providerUnavailable`),+ the `isRetryable` predicate, and the pure `classifyHttpStatus` /+ `classifyHttpStatusWithBody` helpers let callers implement retry+ policy without parsing error text. `ErrorCategory` and `BaikaiError`+ serialize to JSON.+- `Response` and the streaming `EventError`'s `TerminalPayload` now+ carry `errorInfo :: Maybe BaikaiError`, so a failed `completeRequest`+ (or a drained stream) exposes the structured category/retry hint+ in-band. `Baikai.Stream.Event` gains `doneTerminal` / `errorTerminal`+ constructors.++### Changed++- **Breaking:** `BaikaiError`'s four flat constructors+ (`ProviderError`, `RequestInvalid`, `DecodeError`, `ProcessError`)+ were replaced by the record above. Migrate by lowercasing to the+ smart constructors — `ProviderError "x"` becomes `providerError "x"`,+ `ProcessError n "x"` becomes `processError n "x"`, etc.+- **Breaking:** `Baikai.Stream.Event.TerminalPayload` and+ `Baikai.Response.Response` gained an `errorInfo` field; build+ `TerminalPayload` via `doneTerminal` / `errorTerminal`.++### Fixed++- Restored JSON decoding for `BaikaiError` values with omitted optional+ metadata fields.++## [baikai-claude 0.2.0.0] - 2026-06-21++### Added++- The Anthropic API and `claude -p` CLI providers now classify failures+ into the typed `BaikaiError` categories: HTTP errors (via the caught+ `servant-client` `ClientError`) map status/`Retry-After`/body onto+ `AuthError` / `RateLimited` / `ContextOverflow` / `InvalidRequest` /+ `TransientError`, and mid-stream Anthropic `error` events are+ classified by their error type. The result is surfaced on+ `Response.errorInfo`.++## [baikai-openai 0.2.0.0] - 2026-06-21++### Added++- The OpenAI/OpenAI-compatible API and `codex exec` CLI providers now+ classify failures into the typed `BaikaiError` categories the same way+ as `baikai-claude` (HTTP `ClientError` for status-based errors,+ streamed error text for mid-stream errors), surfaced on+ `Response.errorInfo`.++## [baikai-trace-otel 0.2.0.0] - 2026-06-21++### Changed++- Updated the `baikai` dependency bound to `^>=0.2.0` for compatibility with+ the `baikai 0.2.0.0` breaking API release.++## [baikai-effectful 0.2.0.0] - 2026-06-21++### Changed++- Updated the `baikai` dependency bound to `^>=0.2.0` for compatibility with+ the `baikai 0.2.0.0` breaking API release.++## [baikai 0.1.1.0] - 2026-06-12++### Added++- Added provider-agnostic `ResponseFormat` support on `Options`, including+ plain JSON-object mode and named JSON-schema mode.+- Added `Baikai.Embedding`, an OpenAI `/v1/embeddings` client for text+ embeddings.++## [baikai-claude 0.1.1.0] - 2026-06-12++### Added++- Mapped baikai `ResponseFormat` options onto Anthropic `output_config` for+ Claude API requests.+- Exported `mapRequest` for request-mapping tests and downstream inspection.++## [baikai-openai 0.1.1.0] - 2026-06-12++### Added++- Mapped baikai `ResponseFormat` options onto OpenAI Chat Completions+ `response_format`.+- Exported `mapRequest` for request-mapping tests and downstream inspection.++## [baikai-effectful 0.1.0.0] - 2026-06-12++### Added++- Initial release: effectful binding for baikai with the `Baikai` dynamic+ effect, `complete`, `streamCollect`, `streamEach`, and registry-backed+ interpreters.++## [baikai 0.1.0.0] - 2026-06-04++### Added++- Initial release: unified Haskell interface for working with multiple AI+ providers. Core modules including `Baikai`, `Baikai.Prelude`, `Baikai.Api`,+ `Baikai.Provider`, `Baikai.Provider.Registry`, `Baikai.Response`,+ `Baikai.Stream`, `Baikai.Tool`, `Baikai.Trace`, and the cost/usage modules.+- Depends on released `streamly` (`>=0.11 && <0.13`) and `streamly-core`+ (`>=0.3 && <0.5`) from Hackage, so all dependencies resolve from Hackage.++## [baikai-claude 0.1.0.0] - 2026-06-04++### Added++- Initial release: Anthropic Claude providers for the baikai abstraction,+ wrapping the `claude` package for both the Anthropic API and the `claude -p`+ CLI (`Baikai.Provider.Claude.Api`, `.Cli`, `.Interactive`).++## [baikai-openai 0.1.0.0] - 2026-06-04++### Added++- Initial release: OpenAI providers for the baikai abstraction, wrapping the+ `openai` package for OpenAI's Chat Completions API+ (`Baikai.Provider.OpenAI.Api`, `.Cli`, `.Interactive`).++## [baikai-trace-otel 0.1.0.0] - 2026-06-04++### Added++- Initial release: OpenTelemetry `TraceSink` adapter for baikai+ (`Baikai.Trace.Sink.OpenTelemetry`), emitting one OTel span per provider call+ with GenAI semantic-convention attributes plus baikai cost and latency.
baikai-kit.cabal view
@@ -1,25 +1,36 @@ cabal-version: 3.4-name: baikai-kit-version: 0.1.0.4-synopsis: Shared kit installer for AI-agent skills and subagents+name: baikai-kit+version: 0.4.0.0+synopsis: Shared kit installer for AI-agent skills and subagents description: Shared implementation of kit listing, installation, update, uninstall, status, and discovery helpers for command-line tools that install local AI-agent skills and subagents. -category: AI-license: BSD-3-Clause-license-file: LICENSE-author: Nadeem Bitar-maintainer: nadeem@gmail.com-copyright: (c) 2026 Nadeem Bitar-build-type: Simple+category: AI+license: BSD-3-Clause+license-file: LICENSE+author: Nadeem Bitar+maintainer: nadeem@gmail.com+copyright: (c) 2026 Nadeem Bitar+build-type: Simple+tested-with: ghc ==9.12.4+extra-doc-files: CHANGELOG.md+extra-source-files:+ test/fixtures/*.json+ test/golden/*.json common common-options ghc-options:- -Wall -Wcompat -Widentities -Wincomplete-uni-patterns- -Wincomplete-record-updates -Wredundant-constraints- -fhide-source-paths -Wmissing-export-lists -Wpartial-fields+ -Wall+ -Wcompat+ -Widentities+ -Wincomplete-uni-patterns+ -Wincomplete-record-updates+ -Wredundant-constraints+ -fhide-source-paths+ -Wmissing-export-lists+ -Wpartial-fields -Wmissing-deriving-strategies -- Exhaustiveness is an error, not a warning. A non-exhaustive match@@ -34,10 +45,11 @@ -- fail the build on warnings that are stylistic or that a future GHC -- invents, and would push people toward blanket suppression. ghc-options:- -Werror=incomplete-patterns -Werror=incomplete-uni-patterns+ -Werror=incomplete-patterns+ -Werror=incomplete-uni-patterns -Werror=incomplete-record-updates - default-language: GHC2024+ default-language: GHC2024 default-extensions: DeriveAnyClass DuplicateRecordFields@@ -45,49 +57,63 @@ OverloadedStrings library- import: common-options- hs-source-dirs: src+ import: common-options+ hs-source-dirs: src exposed-modules: Baikai.Kit+ Baikai.Kit.CodexConfig Baikai.Kit.Command Baikai.Kit.Config+ Baikai.Kit.Error Baikai.Kit.Install+ Baikai.Kit.Json Baikai.Kit.Manifest Baikai.Kit.Path Baikai.Kit.Repo Baikai.Kit.Session Baikai.Kit.Sidecar Baikai.Kit.Status+ Baikai.Kit.Visibility + other-modules: Baikai.Kit.Link build-depends:- , aeson ^>=2.2- , baikai ^>=0.5.0- , base >=4.20 && <5- , binary ^>=0.8- , bytestring ^>=0.12- , crypton ^>=1.0- , directory ^>=1.3- , filepath ^>=1.5- , optparse-applicative ^>=0.19- , process ^>=1.6- , text ^>=2.1- , time ^>=1.14+ aeson ^>=2.2,+ baikai ^>=0.7.0,+ base >=4.20 && <5,+ binary ^>=0.8,+ bytestring ^>=0.12,+ containers ^>=0.7,+ crypton ^>=1.0,+ directory ^>=1.3,+ filepath ^>=1.5,+ optparse-applicative ^>=0.19,+ process ^>=1.6,+ text ^>=2.1,+ time ^>=1.14,+ toml-parser ^>=2.0.2, test-suite baikai-kit-test- import: common-options- type: exitcode-stdio-1.0+ import: common-options+ type: exitcode-stdio-1.0 hs-source-dirs: test- main-is: Main.hs- ghc-options: -threaded -with-rtsopts=-N+ main-is: Main.hs+ ghc-options:+ -threaded+ -with-rtsopts=-N+ build-depends:- , aeson- , baikai- , baikai-kit- , base- , bytestring- , directory- , filepath- , tasty- , tasty-hunit- , temporary- , text+ aeson,+ baikai,+ baikai-kit,+ base,+ bytestring,+ directory,+ filepath,+ optparse-applicative,+ process,+ tasty,+ tasty-hunit,+ temporary,+ text,+ toml-parser,+ unix,
src/Baikai/Kit.hs view
@@ -1,22 +1,30 @@ module Baikai.Kit ( module Baikai.Kit.Command, module Baikai.Kit.Config,+ module Baikai.Kit.CodexConfig,+ module Baikai.Kit.Error, module Baikai.Kit.Install,+ module Baikai.Kit.Json, module Baikai.Kit.Manifest, module Baikai.Kit.Path, module Baikai.Kit.Repo, module Baikai.Kit.Session, module Baikai.Kit.Sidecar, module Baikai.Kit.Status,+ module Baikai.Kit.Visibility, ) where +import Baikai.Kit.CodexConfig import Baikai.Kit.Command import Baikai.Kit.Config+import Baikai.Kit.Error import Baikai.Kit.Install+import Baikai.Kit.Json import Baikai.Kit.Manifest import Baikai.Kit.Path import Baikai.Kit.Repo import Baikai.Kit.Session import Baikai.Kit.Sidecar import Baikai.Kit.Status+import Baikai.Kit.Visibility
+ src/Baikai/Kit/CodexConfig.hs view
@@ -0,0 +1,298 @@+-- | Preserve the user's TOML text; parse both sides of every surgical edit.+module Baikai.Kit.CodexConfig+ ( codexConfigPath,+ readSkillEntries,+ addDisabledSkill,+ removeDisabledSkill,+ checkDisabledSkill,+ checkRemoveDisabledSkill,+ enableSkillsArgs,+ )+where++import Baikai.Kit.Error (KitError (..))+import Baikai.Prelude+import Control.Exception (IOException, onException, try)+import Control.Monad (forM_, unless, when)+import Data.ByteString qualified as BS+import Data.Char (ord)+import Data.List (nub)+import Data.Map.Strict qualified as Map+import Data.Maybe (fromMaybe)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Encoding+import Numeric (showHex)+import System.Directory+ ( canonicalizePath,+ createDirectoryIfMissing,+ doesDirectoryExist,+ doesFileExist,+ getHomeDirectory,+ getPermissions,+ pathIsSymbolicLink,+ readable,+ removeFile,+ renameFile,+ setPermissions,+ writable,+ )+import System.Environment (lookupEnv)+import System.FilePath (takeDirectory, takeFileName, (</>))+import System.IO (hClose, openTempFile)+import Toml qualified++codexConfigPath :: IO FilePath+codexConfigPath = do+ home <- getHomeDirectory+ root <- fromMaybe (home </> ".codex") <$> lookupEnv "CODEX_HOME"+ pure (root </> "config.toml")++readSkillEntries :: FilePath -> IO (Either KitError [(FilePath, Bool)])+readSkillEntries config = configTry config "" $ do+ text <- readConfig config+ pure $ do+ table <- parseConfig text+ traverse entry (configValues table)+ where+ entry (Toml.Table t) = do+ p <- case value "path" t of+ Just (Toml.Text p) -> pure (Text.unpack p)+ _ -> Left "skills.config entry has no string path"+ enabled <- case value "enabled" t of+ Nothing -> Right True+ Just (Toml.Bool b) -> Right b+ _ -> Left "skills.config entry has no boolean enabled"+ Right (p, enabled)+ entry _ = Left "skills.config must contain tables"++-- | Validate without writing: True means we would add and own this entry.+checkDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)+checkDisabledSkill config skill = fmap (fmap (has _Just)) (prepareEdit True config skill)++checkRemoveDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)+checkRemoveDisabledSkill config skill = fmap (fmap (has _Just)) (prepareEdit False config skill)++addDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)+addDisabledSkill = editSkill True++removeDisabledSkill :: FilePath -> FilePath -> IO (Either KitError Bool)+removeDisabledSkill = editSkill False++editSkill :: Bool -> FilePath -> FilePath -> IO (Either KitError Bool)+editSkill adding config skill = do+ prepared <- prepareEdit adding config skill+ case prepared of+ Left err -> pure (Left err)+ Right Nothing -> pure (Right False)+ Right (Just text) -> configTry config skill $ do+ atomicWrite config text+ pure (Right True)++prepareEdit :: Bool -> FilePath -> FilePath -> IO (Either KitError (Maybe Text))+prepareEdit adding config skill = configTry config skill $ do+ linked <- either (const False) id <$> try @IOException (pathIsSymbolicLink config)+ if linked+ then pure (Left "config.toml is a symbolic link (possibly generated); refusing to replace it")+ else do+ exists <- doesFileExist config+ permissions <- if exists then Just <$> getPermissions config else pure Nothing+ case permissions of+ Just perms | not (readable perms && writable perms) -> ioError (userError "config.toml is not readable and writable")+ _ -> pure ()+ checkParentWritable (takeDirectory config)+ text <- readConfig config+ case parseConfig text of+ Left err -> pure (Left err)+ Right old -> do+ absolute <- canonicalizePath skill+ matches <- traverse (matchesPath absolute) (configValues old)+ let matchedEntries = [v | (v, True) <- zip (configValues old) matches]+ pure $ do+ selected <- case matchedEntries of+ [] -> Right Nothing+ [v@(Toml.Table t)] -> case value "enabled" t of+ Just (Toml.Bool False) -> Right (Just v)+ _+ | adding -> Left "the user already enabled this skill; refusing to override that entry"+ | otherwise -> Right Nothing+ _ -> Left "multiple skills.config entries name this path"+ case (adding, selected) of+ (True, Just _) -> Right Nothing+ (False, Nothing) -> Right Nothing+ _ -> do+ let newText = if adding then appendBlock text absolute else removeBlock text absolute+ expected =+ if adding+ then configValues old ++ [disabledValue absolute]+ else filter (\v -> Just v /= selected) (configValues old)+ new <- parseConfig newText+ unless (configValues new == expected && withoutConfig old == withoutConfig new) $+ Left "the edit would change other TOML values; use array-of-table [[skills.config]] entries"+ Right (Just newText)+ where+ matchesPath absolute (Toml.Table t) = case value "path" t of+ Just (Toml.Text p) -> (== absolute) <$> canonicalizePath (Text.unpack p)+ _ -> pure False+ matchesPath _ _ = pure False++checkParentWritable :: FilePath -> IO ()+checkParentWritable path = do+ exists <- doesDirectoryExist path+ if exists+ then do+ permissions <- getPermissions path+ unless (writable permissions) (ioError (userError "Codex config directory is not writable"))+ else do+ let parent = takeDirectory path+ when (parent == path || parent == "/") (ioError (userError "Codex config has no writable parent"))+ checkParentWritable parent++readConfig :: FilePath -> IO Text+readConfig config = do+ exists <- doesFileExist config+ if not exists+ then pure ""+ else do+ bytes <- BS.readFile config+ either (ioError . userError . show) pure (Encoding.decodeUtf8' bytes)++parseConfig :: Text -> Either Text Toml.Table+parseConfig text = do+ t <- either (Left . Text.pack) (Right . Toml.forgetTableAnns) (Toml.parse text)+ case value "skills" t of+ Nothing -> Right t+ Just (Toml.Table skills) -> case value "config" skills of+ Nothing -> Right t+ Just (Toml.List _) -> Right t+ _ -> Left "skills.config is not an array"+ _ -> Left "skills is not a table"++value :: Text -> Toml.Table -> Maybe Toml.Value+value key (Toml.MkTable t) = snd <$> Map.lookup key t++configValues :: Toml.Table -> [Toml.Value]+configValues t = case value "skills" t of+ Just (Toml.Table skills) -> case value "config" skills of+ Just (Toml.List values) -> values+ _ -> []+ _ -> []++withoutConfig :: Toml.Table -> Toml.Table+withoutConfig (Toml.MkTable t) = Toml.MkTable $ Map.update clean "skills" t+ where+ clean (_, Toml.Table (Toml.MkTable skills)) =+ let rest = Map.delete "config" skills+ in if Map.null rest then Nothing else Just ((), Toml.Table (Toml.MkTable rest))+ clean other = Just other++disabledValue :: FilePath -> Toml.Value+disabledValue skill =+ Toml.Table+ ( Toml.MkTable+ ( Map.fromList+ [("path", ((), Toml.Text (Text.pack skill))), ("enabled", ((), Toml.Bool False))]+ )+ )++disabledBlock :: FilePath -> Text+disabledBlock skill = "[[skills.config]]\npath = " <> tomlString (Text.pack skill) <> "\nenabled = false\n"++-- Mark the separator so uninstall restores even a seed with no final newline.+appendBlock :: Text -> FilePath -> Text+appendBlock text skill = text <> "\n# baikai-kit begin\n" <> disabledBlock skill <> "# baikai-kit end\n"++removeBlock :: Text -> FilePath -> Text+removeBlock text skill =+ let marked = go "" (Text.splitOn "\n# baikai-kit begin\n" text)+ in if marked /= text then marked else removeUnmarked text skill+ where+ go prefix [] = prefix+ go prefix [lastPart] = prefix <> lastPart+ go prefix (part : block : remaining) =+ let (body, suffix) = Text.breakOn "# baikai-kit end\n" block+ matches = case parseConfig body of+ Right table -> configValues table == [disabledValue skill]+ Left _ -> False+ in if matches && not (Text.null suffix)+ then+ prefix+ <> part+ <> Text.drop (Text.length "# baikai-kit end\n") suffix+ <> Text.concat ["\n# baikai-kit begin\n" <> r | r <- remaining]+ else go (prefix <> part <> "\n# baikai-kit begin\n") (block : remaining)++-- A user may remove our delimiter comments. Locate the table block; the+-- caller still verifies that removing it changes exactly one parsed entry.+removeUnmarked :: Text -> FilePath -> Text+removeUnmarked text skill = go [] (Text.splitOn "\n" text)+ where+ go before [] = Text.intercalate "\n" before+ go before (line : rest)+ | Text.strip (Text.takeWhile (/= '#') line) == "[[skills.config]]" =+ let (body, after) = break (Text.isPrefixOf "[" . Text.stripStart) rest+ block = Text.intercalate "\n" (line : body)+ matches = case parseConfig block of+ Right table -> case configValues table of+ [Toml.Table entry] ->+ value "path" entry == Just (Toml.Text (Text.pack skill))+ && value "enabled" entry == Just (Toml.Bool False)+ _ -> False+ Left _ -> False+ in if matches+ then Text.intercalate "\n" (before ++ after)+ else go (before ++ [line]) rest+ | otherwise = go (before ++ [line]) rest++atomicWrite :: FilePath -> Text -> IO ()+atomicWrite path text = do+ let dir = takeDirectory path+ createDirectoryIfMissing True dir+ exists <- doesFileExist path+ permissions <- if exists then Just <$> getPermissions path else pure Nothing+ (temp, handle) <- openTempFile dir (takeFileName path <> ".baikai-kit-tmp")+ ( do+ BS.hPut handle (Encoding.encodeUtf8 text)+ hClose handle+ forM_ permissions (setPermissions temp)+ renameFile temp path+ )+ `onException` (hClose handle >> removeFile temp)++configTry :: FilePath -> FilePath -> IO (Either Text a) -> IO (Either KitError a)+configTry config skill action = do+ result <- try @IOException action+ pure $ case result of+ Left e -> Left (failure (Text.pack (show e)))+ Right (Left reason) -> Left (failure reason)+ Right (Right v) -> Right v+ where+ failure reason =+ KitCodexConfigUnusable+ config+ ( reason+ <> "\nAdd this block by hand:\n"+ <> disabledBlock skill+ <> "Use --shared to install without the entry. --accept-shared-codex applies to agents that cannot be isolated."+ )++enableSkillsArgs :: [FilePath] -> [Text]+enableSkillsArgs [] = []+enableSkillsArgs skills =+ [ "-c",+ "skills.config=["+ <> Text.intercalate+ ","+ ["{path=" <> tomlString (Text.pack p) <> ",enabled=true}" | p <- nub skills]+ <> "]"+ ]++tomlString :: Text -> Text+tomlString input = "\"" <> Text.concatMap escape input <> "\""+ where+ escape '"' = "\\\""+ escape '\\' = "\\\\"+ escape c+ | ord c < 32 || ord c == 127 =+ let digits = showHex (ord c) ""+ in "\\u" <> Text.pack (replicate (4 - length digits) '0' ++ digits)+ escape c = Text.singleton c
src/Baikai/Kit/Command.hs view
@@ -1,60 +1,253 @@+-- | The optparse-applicative adapter a consuming tool wires up as its+-- @kit@ subcommand.+--+-- 'runKit' is the only function in @baikai-kit@ that exits the process;+-- see @docs/adr/0013-library-code-never-calls-exitfailure.md@. module Baikai.Kit.Command ( KitCommand (..),+ OutputFormat (..), kitCommandParser, runKit,+ runKitCommand, ) where -import Baikai.Kit.Config (KitConfig, KitScope (..))-import Baikai.Kit.Install (installItem, listAvailable, uninstallItem, updateKit)-import Baikai.Kit.Status (kitStatus)+import Baikai.Kit.Config (KitConfig, KitScope (..), scopeLabel)+import Baikai.Kit.Error (KitError (..), renderKitError)+import Baikai.Kit.Install+ ( InstallOptions (..),+ OverwritePolicy (..),+ UpdateReport,+ installFrom,+ loadManifest,+ renderAvailable,+ renderUninstallReport,+ uninstallItem,+ updateKit,+ )+import Baikai.Kit.Json (listDocument, statusDocument, updateDocument)+import Baikai.Kit.Manifest (KitManifest, itemKind, itemName)+import Baikai.Kit.Repo (KitRepo, RepoRefresh (..), ensureKitRepo)+import Baikai.Kit.Status (StatusReport, UpstreamAvailability (..), installedCopies, kitStatus, renderStatusTable)+import Baikai.Kit.Visibility (KitVisibility (..)) import Baikai.Prelude+import Data.Aeson (Value)+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as LBS+import Data.Text qualified as Text+import Data.Text.IO qualified as Text.IO import Options.Applicative+import System.Exit (ExitCode (ExitFailure), exitWith)+import System.IO (Handle, stderr, stdout) +-- | How @list@, @status@ and @update@ print their result: the terminal+-- table, or exactly one JSON document on stdout (see "Baikai.Kit.Json").+data OutputFormat+ = HumanOutput+ | JsonOutput+ deriving stock (Eq, Show)+ data KitCommand- = KitList- | KitInstall !Text !KitScope- | KitUpdate !(Maybe Text)+ = KitList !OutputFormat+ | -- | 'Nothing' asks the configured 'Baikai.Kit.Config.chooseItem'.+ KitInstall !(Maybe Text) !KitScope !InstallOptions+ | KitUpdate !(Maybe Text) !OverwritePolicy !OutputFormat | KitUninstall !Text !KitScope- | KitStatus- deriving stock (Show)+ | KitStatus !OutputFormat+ deriving stock (Eq, Show) -kitCommandParser :: Parser KitCommand-kitCommandParser =+-- | The @kit@ subcommands. Takes the configuration so help text can name+-- the tool's own project directory.+kitCommandParser :: KitConfig -> Parser KitCommand+kitCommandParser config = hsubparser- ( command "list" (info (pure KitList) (progDesc "List available skills and subagents"))- <> command "install" (info installParser (progDesc "Install a skill or subagent"))+ ( command "list" (info (KitList <$> formatParser) (progDesc "List available skills and subagents"))+ <> command "install" (info (installParser config) (progDesc "Install a skill or subagent")) <> command "update" (info updateParser (progDesc "Update installed skills and subagents"))- <> command "uninstall" (info uninstallParser (progDesc "Uninstall a skill or subagent"))- <> command "status" (info (pure KitStatus) (progDesc "Show installed skills and subagents"))+ <> command "uninstall" (info (uninstallParser config) (progDesc "Uninstall a skill or subagent"))+ <> command "status" (info (KitStatus <$> formatParser) (progDesc "Show installed skills and subagents")) )- <|> pure KitList+ <|> pure (KitList HumanOutput) +-- | Run one verb and print its normal output. Never exits, so a consumer+-- that wants its own exit codes can map the 'KitError' itself.+runKitCommand :: KitConfig -> KitCommand -> IO (Either KitError ())+runKitCommand config = \case+ KitList HumanOutput -> withRepo HumanOutput $ \repo ->+ loadManifest (repo ^. #dir) `thenE` \manifest ->+ printed (renderAvailable manifest)+ KitList JsonOutput -> withRepo JsonOutput $ \repo ->+ loadManifest (repo ^. #dir) `thenE` \manifest -> do+ copies <- installedCopies config+ emit (listDocument (repoAvailability repo) manifest copies)+ KitInstall (Just n) scope options -> withRepo HumanOutput $ \repo ->+ loadManifest (repo ^. #dir) `thenE` \manifest ->+ installNamed repo manifest n scope options+ -- Without a chooser nothing the refresh could do changes the outcome,+ -- so fail before touching the network.+ KitInstall Nothing scope options -> case config ^. #chooseItem of+ Nothing -> pure (Left KitItemNameRequired)+ Just choose -> withRepo HumanOutput $ \repo ->+ loadManifest (repo ^. #dir) `thenE` \manifest -> do+ picked <- choose manifest+ case picked of+ Nothing -> printed "No item chosen; nothing installed."+ Just n -> installNamed repo manifest n scope options+ KitUpdate n policy HumanOutput ->+ updateKit config n policy `thenE` (printed . renderUpdateReport)+ KitUpdate n policy JsonOutput ->+ updateKit config n policy `thenE` (emit . updateDocument)+ KitUninstall n scope ->+ uninstallItem config n scope `thenE` (printed . renderUninstallReport n scope)+ KitStatus format -> do+ report <- kitStatus config+ noteUpstream report+ case format of+ HumanOutput -> printed (renderStatusTable (report ^. #rows))+ JsonOutput -> emit (statusDocument report)+ where+ installNamed :: KitRepo -> KitManifest -> Text -> KitScope -> InstallOptions -> IO (Either KitError ())+ installNamed repo manifest n scope options =+ installFrom config (repo ^. #dir) manifest n scope options `thenE` \item ->+ printed $+ "Installed " <> itemKind item <> " '" <> itemName item <> "' to " <> scopeLabel scope <> " scope."++ -- List and install need the manifest, so a repository they cannot+ -- reach is an error; a stale cache is a warning and the work goes on.+ -- In JSON mode stdout carries only the document, so the clone notice+ -- goes to stderr with the warnings.+ withRepo :: OutputFormat -> (KitRepo -> IO (Either KitError ())) -> IO (Either KitError ())+ withRepo format next = do+ repo <- ensureKitRepo config+ case repo of+ Left err -> pure (Left err)+ Right resolved -> do+ case resolved ^. #refresh of+ RepoStale err ->+ Text.IO.hPutStrLn stderr $+ "Warning: kit repository could not be refreshed (" <> Text.strip err <> "); using the cached copy."+ RepoCloned ->+ Text.IO.hPutStrLn (noticeHandle format) ("Fetched " <> (config ^. #toolName) <> "-kit.")+ RepoPulled -> pure ()+ next resolved++ noteUpstream :: StatusReport -> IO ()+ noteUpstream report = case report ^. #upstream of+ UpstreamReady -> pure ()+ UpstreamStale err ->+ Text.IO.hPutStrLn stderr $+ "Warning: kit repository could not be refreshed ("+ <> Text.strip err+ <> "); comparing against the cached copy."+ UpstreamUnavailable err ->+ Text.IO.hPutStrLn stderr $+ "Note: kit repository unavailable ("+ <> Text.strip (renderKitError err)+ <> "); showing installed items without upstream comparison."++ thenE :: IO (Either KitError a) -> (a -> IO (Either KitError b)) -> IO (Either KitError b)+ thenE step next = step >>= either (pure . Left) next++ printed :: Text -> IO (Either KitError ())+ printed message = Right <$> Text.IO.putStrLn message++ -- One document, UTF-8 encoded whatever the locale (ADR 0007).+ emit :: Value -> IO (Either KitError ())+ emit document = Right <$> LBS.hPut stdout (Aeson.encode document <> "\n")++ noticeHandle :: OutputFormat -> Handle+ noticeHandle HumanOutput = stdout+ noticeHandle JsonOutput = stderr++ repoAvailability :: KitRepo -> UpstreamAvailability+ repoAvailability repo = case repo ^. #refresh of+ RepoStale err -> UpstreamStale err+ RepoCloned -> UpstreamReady+ RepoPulled -> UpstreamReady++-- | The command adapter: 'runKitCommand', then on 'Left' print+-- @Error: \<renderKitError e\>@ to stderr and exit 1. This is the only+-- function in @baikai-kit@ that exits the process. runKit :: KitConfig -> KitCommand -> IO ()-runKit config = \case- KitList -> listAvailable config- KitInstall n scope -> installItem config n scope- KitUpdate n -> updateKit config n- KitUninstall n scope -> uninstallItem config n scope- KitStatus -> kitStatus config+runKit config kitCommand = do+ result <- runKitCommand config kitCommand+ case result of+ Right () -> pure ()+ Left err -> do+ Text.IO.hPutStrLn stderr ("Error: " <> renderKitError err)+ exitWith (ExitFailure 1) -installParser :: Parser KitCommand-installParser =+renderUpdateReport :: UpdateReport -> Text+renderUpdateReport report =+ Text.intercalate "\n" (headline ++ updatedLines ++ skippedLines ++ [summary] ++ skipSummary)+ where+ headline = case report ^. #refresh of+ Nothing -> []+ Just RepoCloned -> ["Kit repository cloned."]+ Just RepoPulled -> ["Kit repository updated."]+ Just (RepoStale _) -> ["Kit repository updated."]+ updatedLines =+ [ "Updated '" <> n <> "' (" <> scopeLabel scope <> ")"+ | (n, scope) <- report ^. #updated+ ]+ -- A skip is not a failure: the command still exits 0, and the line+ -- says exactly which invocation would overwrite the edits.+ skippedLines =+ [ "Skipped '"+ <> n+ <> "' ("+ <> scopeLabel scope+ <> "): installed files were modified locally; run 'kit update "+ <> n+ <> " --force' to overwrite."+ | (n, scope) <- report ^. #skipped+ ]+ summary = "Updated " <> Text.pack (show (length (report ^. #updated))) <> " item(s)."+ skipSummary+ | null (report ^. #skipped) = []+ | otherwise = ["Skipped " <> Text.pack (show (length (report ^. #skipped))) <> " item(s)."]++installParser :: KitConfig -> Parser KitCommand+installParser config = KitInstall- <$> strArgument (metavar "NAME" <> help "Name of the skill or subagent to install")- <*> scopeParser "Install to project scope instead of user scope"+ <$> optional+ ( strArgument+ ( metavar "NAME"+ <> help "Name of the skill or subagent to install; omit it to choose interactively if this tool offers a chooser"+ )+ )+ <*> scopeParser ("Install to project scope (" <> projectDirLabel config <> " under the project root) instead of user scope")+ <*> ( InstallOptions+ <$> optional+ ( flag' SharedVisibility (long "shared" <> help "Make the item visible in every Claude Code and Codex session")+ <|> flag' ToolOnlyVisibility (long "tool-only" <> help "Make the item visible only in sessions this tool launches")+ )+ <*> switch (long "accept-shared-codex" <> help "Accept shared visibility when Codex cannot isolate an agent")+ ) updateParser :: Parser KitCommand updateParser = KitUpdate <$> optional (strArgument (metavar "NAME" <> help "Name of a specific item to update (default: all)"))+ <*> flag+ KeepLocalEdits+ OverwriteLocalEdits+ (long "force" <> help "Reinstall items even if their installed files were modified locally")+ <*> formatParser -uninstallParser :: Parser KitCommand-uninstallParser =+formatParser :: Parser OutputFormat+formatParser = flag HumanOutput JsonOutput (long "json" <> help "Print one JSON document on stdout")++uninstallParser :: KitConfig -> Parser KitCommand+uninstallParser config = KitUninstall <$> strArgument (metavar "NAME" <> help "Name of the skill or subagent to uninstall")- <*> scopeParser "Uninstall from project scope instead of user scope"+ <*> scopeParser ("Uninstall from project scope (" <> projectDirLabel config <> ") instead of user scope") scopeParser :: String -> Parser KitScope scopeParser helpText = flag UserScope ProjectScope (long "project" <> help helpText)++-- | The tool's project directory as help text shows it, e.g. @.mytool/agents@.+projectDirLabel :: KitConfig -> String+projectDirLabel config = "." <> Text.unpack (config ^. #toolName) <> "/agents"
src/Baikai/Kit/Config.hs view
@@ -1,11 +1,15 @@ module Baikai.Kit.Config ( KitConfig (..), KitScope (..),+ kitConfig,+ findProjectRoot,+ projectRootByMarkers, kitCacheDir, userAgentsDir, projectAgentsDir, resolveAgentsBase, providerAgentsBase,+ sharedClaudeBase, providerLabel, sidecarFileName, scopeLabel,@@ -14,18 +18,92 @@ import Baikai.AgentAssets (AgentAssetProvider) import Baikai.Interactive (InteractiveProvider (..))+import Baikai.Kit.Manifest (KitItem, KitManifest) import Baikai.Prelude+import Data.Maybe (fromMaybe) import Data.Text qualified as Text-import System.Directory (getCurrentDirectory, getHomeDirectory)-import System.FilePath ((</>))+import System.Directory (doesPathExist, getCurrentDirectory, getHomeDirectory, makeAbsolute)+import System.FilePath (takeDirectory, (</>)) +-- | How a tool configures the kit engine. Build one with 'kitConfig' and+-- override optional fields with record update syntax; a record literal+-- must set every field. data KitConfig = KitConfig { toolName :: !Text, repoUrl :: !Text,- providers :: ![AgentAssetProvider]+ providers :: ![AgentAssetProvider],+ -- | The directory project scope lives under. It is run once per+ -- project-scope path lookup, so install, status, update, uninstall,+ -- and 'Baikai.Kit.Session.agentDirsForSession' all agree on it. The+ -- default ('kitConfig') is the current directory; 'projectRootByMarkers'+ -- walks up to the nearest marker such as @.git@. An exception thrown+ -- by this action propagates to the caller.+ projectRoot :: !(IO FilePath),+ -- | Called by @kit install@ when no name is given, with the whole+ -- manifest. 'Just' a name installs that item (a name the manifest+ -- does not list fails with 'Baikai.Kit.Error.KitItemNotFound');+ -- 'Nothing' means the user cancelled, and nothing is installed. When+ -- this field is 'Nothing', @kit install@ without a name fails with+ -- 'Baikai.Kit.Error.KitItemNameRequired'. The engine ships no picker:+ -- the tool owns presentation. An exception thrown by the chooser+ -- propagates to the caller.+ chooseItem :: !(Maybe (KitManifest -> IO (Maybe Text))),+ -- | Confirmation for a tool-only agent whose Codex copy must be shared.+ confirmSharedCodex :: !(Maybe (KitItem -> IO Bool)) }- deriving stock (Generic, Show)+ deriving stock (Generic) +instance Show KitConfig where+ showsPrec d config =+ showParen (d > 10) $+ showString "KitConfig {toolName = "+ . shows (config ^. #toolName)+ . showString ", repoUrl = "+ . shows (config ^. #repoUrl)+ . showString ", providers = "+ . shows (config ^. #providers)+ . showString ", projectRoot = <IO FilePath>, chooseItem = "+ . showString (maybe "Nothing" (const "Just <chooser>") (config ^. #chooseItem))+ . showString ", confirmSharedCodex = "+ . showString (maybe "Nothing" (const "Just <confirmation>") (config ^. #confirmSharedCodex))+ . showString "}"++-- | A configuration with every optional behaviour at its default:+-- project scope is the current directory, and @kit install@ requires a+-- name (no chooser).+kitConfig :: Text -> Text -> [AgentAssetProvider] -> KitConfig+kitConfig toolName repoUrl providers =+ KitConfig+ { toolName,+ repoUrl,+ providers,+ projectRoot = getCurrentDirectory,+ chooseItem = Nothing,+ confirmSharedCodex = Nothing+ }++-- | The nearest directory, starting at @start@ and walking towards the+-- filesystem root, that contains any of @markers@ (a file or a+-- directory, e.g. @.git@ or @.mytool@). 'Nothing' if none does.+findProjectRoot :: [FilePath] -> FilePath -> IO (Maybe FilePath)+findProjectRoot markers start = makeAbsolute start >>= go+ where+ go dir = do+ found <- or <$> traverse (doesPathExist . (dir </>)) markers+ if found+ then pure (Just dir)+ else+ let parent = takeDirectory dir+ in if parent == dir then pure Nothing else go parent++-- | A ready-made 'projectRoot': the nearest ancestor of the current+-- directory holding one of @markers@, or the current directory itself+-- when there is none.+projectRootByMarkers :: [FilePath] -> IO FilePath+projectRootByMarkers markers = do+ cwd <- getCurrentDirectory+ fromMaybe cwd <$> findProjectRoot markers cwd+ data KitScope = UserScope | ProjectScope@@ -41,10 +119,12 @@ home <- getHomeDirectory pure (home </> ".config" </> Text.unpack (config ^. #toolName) </> "agents") +-- | Every project-scope path derives from 'projectRoot' through this+-- function, 'resolveAgentsBase', or 'providerAgentsBase'. projectAgentsDir :: KitConfig -> IO FilePath projectAgentsDir config = do- cwd <- getCurrentDirectory- pure (cwd </> "." <> Text.unpack (config ^. #toolName) </> "agents")+ root <- config ^. #projectRoot+ pure (root </> "." <> Text.unpack (config ^. #toolName) </> "agents") resolveAgentsBase :: KitConfig -> KitScope -> IO FilePath resolveAgentsBase config UserScope = userAgentsDir config@@ -53,7 +133,7 @@ providerAgentsBase :: KitConfig -> AgentAssetProvider -> KitScope -> IO FilePath providerAgentsBase config InteractiveClaude scope = resolveAgentsBase config scope providerAgentsBase _config InteractiveCodex UserScope = getHomeDirectory-providerAgentsBase _config InteractiveCodex ProjectScope = getCurrentDirectory+providerAgentsBase config InteractiveCodex ProjectScope = config ^. #projectRoot providerLabel :: AgentAssetProvider -> Text providerLabel InteractiveClaude = "claude"@@ -65,3 +145,8 @@ scopeLabel :: KitScope -> Text scopeLabel UserScope = "user" scopeLabel ProjectScope = "project"++-- | The provider-native shared root; project scope uses the configured root.+sharedClaudeBase :: KitConfig -> KitScope -> IO FilePath+sharedClaudeBase _ UserScope = getHomeDirectory+sharedClaudeBase config ProjectScope = config ^. #projectRoot
+ src/Baikai/Kit/Error.hs view
@@ -0,0 +1,122 @@+-- | Every way a kit operation can fail.+--+-- Library functions return these; only 'Baikai.Kit.Command.runKit'+-- turns one into a process exit. The 'Exception' instance exists for a+-- caller who prefers exceptions and can write @either throwIO pure@.+module Baikai.Kit.Error+ ( KitError (..),+ renderKitError,+ )+where++import Baikai.Prelude+import Control.Exception (Exception)+import Data.Text qualified as Text++-- | A kit operation's failure. Constructors are positional because+-- @-Wpartial-fields@ is on and a record sum would warn.+data KitError+ = -- | The kit checkout holds no @kit.json@.+ KitManifestMissing FilePath+ | -- | The manifest did not decode; the 'Text' is aeson's message.+ KitManifestInvalid FilePath Text+ | -- | The manifest declares a @version@ this installer does not support.+ KitManifestVersionUnsupported FilePath Int+ | -- | No skill or agent of that name is listed.+ KitItemNotFound Text+ | -- | @kit install@ was given no name and the tool supplies no chooser+ -- ('Baikai.Kit.Config.chooseItem' is 'Nothing').+ KitItemNameRequired+ | -- | The item lists no source files.+ KitItemHasNoFiles Text+ | -- | An item name failed 'Baikai.Kit.Path.safeItemName'; the second+ -- 'Text' is that function's reason.+ KitUnsafeName Text Text+ | -- | A manifest path failed 'Baikai.Kit.Path.safeRelativePath'.+ KitUnsafePath Text Text+ | -- | A listed source file is not there.+ KitSourceMissing FilePath+ | -- | A component of a listed source path is a symbolic link. A kit is+ -- plain files; see 'Baikai.Kit.Path.safeSourcePath'.+ KitSourceSymlink FilePath+ | -- | The canonical source path (first field) is not below the+ -- canonical kit checkout (second field).+ KitSourceEscapes FilePath FilePath+ | -- | Inspecting or reading a source raised an 'IOException'.+ KitSourceUnreadable FilePath Text+ | -- | A first clone failed and no usable cache exists: the repository+ -- URL and git's output.+ KitCloneFailed Text Text+ | -- | @git pull@ failed during @kit update@.+ KitPullFailed Text+ | -- | An install could not be completed: the reason, the destinations+ -- 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)++-- | The message a command adapter prints. One line, except where a+-- failure needs to say what it left behind.+renderKitError :: KitError -> Text+renderKitError = \case+ KitManifestMissing path ->+ "kit.json not found in kit repository (" <> Text.pack path <> ")."+ KitManifestInvalid path reason ->+ "failed to parse " <> Text.pack path <> ": " <> reason+ -- The supported versions are spelled out rather than read from+ -- 'Baikai.Kit.Manifest.supportedManifestVersions': Manifest imports+ -- this module, so the dependency cannot run the other way.+ KitManifestVersionUnsupported path n ->+ Text.pack path+ <> " declares manifest version "+ <> Text.pack (show n)+ <> "; this installer supports versions 1 and 2."+ KitItemNotFound n -> "'" <> n <> "' not found in kit manifest."+ KitItemNameRequired ->+ "no item name given: pass NAME to 'kit install' (run 'kit list' to see what is available)."+ KitItemHasNoFiles n -> "'" <> n <> "' lists no source files."+ KitUnsafeName raw reason -> "unsafe item name '" <> raw <> "': " <> reason+ KitUnsafePath raw reason -> "unsafe manifest path '" <> raw <> "': " <> reason+ KitSourceMissing path -> "source file does not exist: " <> Text.pack path+ KitSourceSymlink path -> "refusing symbolic link in kit source: " <> Text.pack path+ KitSourceEscapes path root ->+ "kit source resolves outside the kit checkout: "+ <> Text.pack path+ <> " (checkout: "+ <> Text.pack root+ <> ")"+ KitSourceUnreadable path reason ->+ "cannot inspect kit source " <> Text.pack path <> ": " <> reason+ KitCloneFailed url output ->+ "failed to fetch kit repository " <> url <> ": " <> output+ KitPullFailed output ->+ "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+ aftermath+ | null restored && null leftInconsistent = ["No changes were made."]+ | otherwise =+ ["Restored: " <> renderPaths restored | not (null restored)]+ <> [ "Left inconsistent (repair by reinstalling): " <> renderPaths leftInconsistent+ | not (null leftInconsistent)+ ]+ renderPaths = Text.intercalate ", " . map Text.pack
src/Baikai/Kit/Install.hs view
@@ -1,414 +1,993 @@-module Baikai.Kit.Install- ( loadManifest,- loadManifestMaybe,- lookupItem,- installItem,- uninstallItem,- uninstallOutcomes,- renderUninstallReport,- RemovalOutcome (..),- updateKit,- listAvailable,- stripYamlFrontmatter,- )-where--import Baikai.AgentAssets- ( CodexCustomAgent (..),- agentTargetPath,- codexCustomAgentToml,- skillTargetPath,- )-import Baikai.Interactive (InteractiveProvider (..), InteractiveScope (InteractiveProjectScope))-import Baikai.Kit.Config (KitConfig, KitScope (..), kitCacheDir, providerAgentsBase, providerLabel, scopeLabel, sidecarFileName)-import Baikai.Kit.Manifest- ( AgentEntry,- KitItem (..),- KitItemKind (..),- KitManifest (..),- SkillEntry,- itemKind,- kitItemKind,- )-import Baikai.Kit.Path (safeItemName, safeRelativePath)-import Baikai.Kit.Repo (PullResult (..), ensureKitRepo, pullKitRepo)-import Baikai.Kit.Sidecar (computeKitHash, newSidecarMeta, sidecarPath)-import Baikai.Prelude-import Control.Exception (IOException, catch, throwIO, try)-import Control.Monad (forM, forM_, unless, when)-import Data.Aeson (eitherDecodeFileStrict', encode)-import Data.ByteString.Lazy qualified as LBS-import Data.List (find, nub)-import Data.Text qualified as Text-import Data.Text.Encoding qualified as Text.Encoding-import Data.Text.IO qualified as Text.IO-import System.Directory- ( createDirectoryIfMissing,- doesDirectoryExist,- doesFileExist,- removeDirectoryRecursive,- removeFile,- renameFile,- )-import System.Exit (exitFailure)-import System.FilePath (takeDirectory, takeFileName, (</>))-import System.IO (hPutStrLn, stderr)--data PlannedWrite = PlannedWrite- { destination :: !FilePath,- content :: !WriteContent- }--data WriteContent- = CopyFrom !FilePath- | WriteBytes !LBS.ByteString--data RemovalOutcome = RemovalOutcome- { provider :: !InteractiveProvider,- skillRemoved :: !Bool,- agentRemoved :: !Bool,- sidecarRemoved :: !Bool- }- deriving stock (Eq, Generic, Show)--loadManifest :: FilePath -> IO KitManifest-loadManifest repoDir = do- let manifestPath = repoDir </> "kit.json"- exists <- doesFileExist manifestPath- unless exists $ do- hPutStrLn stderr "Error: kit.json not found in kit repository."- exitFailure- result <- eitherDecodeFileStrict' manifestPath- case result of- Left err -> do- hPutStrLn stderr $ "Error: Failed to parse kit.json: " <> err- exitFailure- Right manifest -> pure manifest--loadManifestMaybe :: FilePath -> IO (Maybe KitManifest)-loadManifestMaybe "" = pure Nothing-loadManifestMaybe cacheDir = do- let manifestPath = cacheDir </> "kit.json"- exists <- doesFileExist manifestPath- if not exists- then pure Nothing- else do- result <- eitherDecodeFileStrict' manifestPath- case result of- Left err -> do- hPutStrLn stderr $ "Warning: failed to parse kit.json: " <> err- pure Nothing- Right manifest -> pure (Just manifest)--lookupItem :: Text -> KitManifest -> Maybe KitItem-lookupItem n manifest =- case find (\entry -> entry ^. #name == n) (manifest ^. #skills) of- Just skill -> Just (KitSkillItem skill)- Nothing -> KitAgentItem <$> find (\entry -> entry ^. #name == n) (manifest ^. #agents)--installItem :: KitConfig -> Text -> KitScope -> IO ()-installItem config itemN scope = do- repoDir <- ensureKitRepo config- manifest <- loadManifest repoDir- case lookupItem itemN manifest of- Nothing -> do- hPutStrLn stderr $ "Error: '" <> Text.unpack itemN <> "' not found in kit manifest."- exitFailure- Just item -> do- result <- try @IOException (doInstall config repoDir item scope)- case result of- Right () ->- Text.IO.putStrLn $- "Installed " <> itemKind item <> " '" <> itemN <> "' to " <> scopeLabel scope <> " scope."- Left e -> do- hPutStrLn stderr $ "Error: install failed, no changes were made: " <> show e- exitFailure--uninstallItem :: KitConfig -> Text -> KitScope -> IO ()-uninstallItem config n scope = do- outcomes <- uninstallOutcomes config n scope- Text.IO.putStrLn (renderUninstallReport n scope outcomes)--uninstallOutcomes :: KitConfig -> Text -> KitScope -> IO [RemovalOutcome]-uninstallOutcomes config n scope = do- safeName <- requireSafe "item name" (safeItemName n)- forM (config ^. #providers) $ \provider -> do- providerBase <- providerAgentsBase config provider scope- skillRemoved <- removeIfDirectory (skillTarget config provider providerBase safeName)- agentRemoved <- removeIfFile (agentTarget config provider providerBase safeName)- sidecarRemoved <- removeIfFile (agentSidecarTarget config provider providerBase safeName)- pure RemovalOutcome {provider, skillRemoved, agentRemoved, sidecarRemoved}--renderUninstallReport :: Text -> KitScope -> [RemovalOutcome] -> Text-renderUninstallReport n scope outcomes- | any assetRemoved outcomes =- "Uninstalled "- <> Text.intercalate "+" removedKinds- <> " '"- <> n- <> "' from "- <> scopeLabel scope- <> " scope ("- <> Text.intercalate "," removedProviders- <> ")."- | any (^. #sidecarRemoved) outcomes =- "Removed stale kit metadata for '" <> n <> "' from " <> scopeLabel scope <> " scope."- | otherwise =- "'" <> n <> "' is not installed in " <> scopeLabel scope <> " scope."- where- assetRemoved outcome = outcome ^. #skillRemoved || outcome ^. #agentRemoved- removedKinds =- nub $- ["skill" | any (^. #skillRemoved) outcomes]- ++ ["agent" | any (^. #agentRemoved) outcomes]- removedProviders = map (providerLabel . view #provider) (filter assetRemoved outcomes)--updateKit :: KitConfig -> Maybe Text -> IO ()-updateKit config mName = do- cacheDir <- kitCacheDir config- exists <- doesDirectoryExist cacheDir- if exists- then do- result <- pullKitRepo config cacheDir- case result of- PullSucceeded -> Text.IO.putStrLn "Kit repository updated."- PullFailed err -> do- hPutStrLn stderr $ "Error: failed to update kit repository: " <> Text.unpack err- hPutStrLn stderr "The cached copy is unchanged; installed items were not reinstalled."- exitFailure- else do- _ <- ensureKitRepo config- Text.IO.putStrLn "Kit repository cloned."- manifest <- loadManifest =<< kitCacheDir config- case mName of- Just n -> do- reinstallIfPresent config n UserScope manifest- reinstallIfPresent config n ProjectScope manifest- Nothing -> reinstallAllPresent config manifest--listAvailable :: KitConfig -> IO ()-listAvailable config = do- repoDir <- ensureKitRepo config- manifest <- loadManifest repoDir- let sk = manifest ^. #skills- ag = manifest ^. #agents- if null sk && null ag- then Text.IO.putStrLn "No items available in the kit."- else do- unless (null sk) $ do- Text.IO.putStrLn "Skills:"- let maxLen = maximum $ map (Text.length . view #name) sk- mapM_ (printEntry maxLen . skillNameDesc) sk- unless (null ag) $ do- unless (null sk) (Text.IO.putStrLn "")- Text.IO.putStrLn "Agents:"- let maxLen = maximum $ map (Text.length . view #name) ag- mapM_ (printEntry maxLen . agentNameDesc) ag- where- printEntry maxLen (n, desc) =- Text.IO.putStrLn $ " " <> Text.justifyLeft (maxLen + 2) ' ' n <> desc--doInstall :: KitConfig -> FilePath -> KitItem -> KitScope -> IO ()-doInstall config repoDir item scope =- planInstall config repoDir item scope >>= executePlan--planInstall :: KitConfig -> FilePath -> KitItem -> KitScope -> IO [PlannedWrite]-planInstall config repoDir item@(KitSkillItem entry) scope = do- safeName <- requireSafe "skill name" (safeItemName (entry ^. #name))- safePath <- requireSafe "skill path" (safeRelativePath (entry ^. #path))- safeFiles <- traverse (requireSafe "skill file" . safeRelativePath) (entry ^. #files)- let sourceDir = repoDir </> safePath- forM_ safeFiles $ \file -> requireSourceFile (sourceDir </> file)- hashStr <- computeKitHash sourceDir (map Text.pack safeFiles)- meta <- newSidecarMeta item hashStr- fmap concat $- forM (config ^. #providers) $ \provider -> do- targetBase <- providerAgentsBase config provider scope- let targetDir = skillTarget config provider targetBase safeName- fileWrites =- [ PlannedWrite- { destination = targetDir </> file,- content = CopyFrom (sourceDir </> file)- }- | file <- safeFiles- ]- sidecarWrite =- PlannedWrite- { destination = sidecarPath provider (kitItemKind item) (Text.pack safeName) targetBase (sidecarFileName config),- content = WriteBytes (encode meta)- }- pure (fileWrites ++ [sidecarWrite])-planInstall config repoDir item@(KitAgentItem entry) scope = do- safeName <- requireSafe "agent name" (safeItemName (entry ^. #name))- (sourceBase, relFiles, primarySource) <- safeAgentSources repoDir entry- forM_ relFiles $ \file -> requireSourceFile (sourceBase </> file)- hashStr <- computeKitHash sourceBase (map Text.pack relFiles)- meta <- newSidecarMeta item hashStr- body <- Text.IO.readFile primarySource- fmap concat $- forM (config ^. #providers) $ \provider -> do- targetBase <- providerAgentsBase config provider scope- let dstFile = agentTarget config provider targetBase safeName- agentWrite =- case provider of- InteractiveClaude ->- PlannedWrite {destination = dstFile, content = CopyFrom primarySource}- InteractiveCodex ->- PlannedWrite- { destination = dstFile,- content = WriteBytes (LBS.fromStrict (Text.Encoding.encodeUtf8 (agentAsCodexToml entry body)))- }- sidecarWrite =- PlannedWrite- { destination = sidecarPath provider (kitItemKind item) (Text.pack safeName) targetBase (sidecarFileName config),- content = WriteBytes (encode meta)- }- pure [agentWrite, sidecarWrite]--safeAgentSources :: FilePath -> AgentEntry -> IO (FilePath, [FilePath], FilePath)-safeAgentSources repoDir entry =- case entry ^. #files of- Just files -> do- safePath <- requireSafe "agent path" (safeRelativePath (entry ^. #path))- safeFiles <- traverse (requireSafe "agent file" . safeRelativePath) files- case safeFiles of- [] -> do- hPutStrLn stderr $ "Error: agent '" <> Text.unpack (entry ^. #name) <> "' has no source files."- exitFailure- primaryRel : _ -> do- let sourceBase = repoDir </> safePath- pure (sourceBase, safeFiles, sourceBase </> primaryRel)- Nothing -> do- safePath <- requireSafe "agent path" (safeRelativePath (entry ^. #path))- let fileName = takeFileName safePath- pure (repoDir </> takeDirectory safePath, [fileName], repoDir </> safePath)--requireSourceFile :: FilePath -> IO ()-requireSourceFile path = do- exists <- doesFileExist path- unless exists (ioError (userError ("source file does not exist: " <> path)))--executePlan :: [PlannedWrite] -> IO ()-executePlan writes = do- tempPaths <- phaseOne [] writes- forM_ tempPaths $ \(temp, final) -> renameFile temp final- where- phaseOne temps [] = pure (reverse temps)- phaseOne temps (PlannedWrite {destination, content} : rest) = do- let temp = destination <> ".baikai-kit-tmp"- result <-- try @IOException $ do- createDirectoryIfMissing True (takeDirectory destination)- writeTemp content temp- pure (temp, destination)- case result of- Left e -> cleanupTemps temps >> throwIO e- Right tempPair ->- phaseOne (tempPair : temps) rest- `catch` \(e :: IOException) -> cleanupTemps (tempPair : temps) >> throwIO e-- writeTemp (CopyFrom src) temp = LBS.readFile src >>= LBS.writeFile temp- writeTemp (WriteBytes bytes) temp = LBS.writeFile temp bytes-- cleanupTemps temps =- forM_ temps $ \(temp, _) -> do- _ <- try @IOException (removeFile temp)- pure ()--requireSafe :: Text -> Either Text a -> IO a-requireSafe what = \case- Right a -> pure a- Left reason -> do- hPutStrLn stderr $ "Error: unsafe " <> Text.unpack what <> ": " <> Text.unpack reason- exitFailure--reinstallIfPresent :: KitConfig -> Text -> KitScope -> KitManifest -> IO ()-reinstallIfPresent config n scope manifest = do- installed <- isInstalled config n scope- when installed $- case lookupItem n manifest of- Nothing -> pure ()- Just item -> do- repoDir <- kitCacheDir config- doInstall config repoDir item scope- Text.IO.putStrLn $ "Updated '" <> n <> "' (" <> scopeLabel scope <> ")"--reinstallAllPresent :: KitConfig -> KitManifest -> IO ()-reinstallAllPresent config manifest = do- let allNames =- map (view #name) (manifest ^. #skills)- ++ map (view #name) (manifest ^. #agents)- repoDir <- kitCacheDir config- updated <- fmap sum $ forM allNames $ \n -> do- userInstalled <- isInstalled config n UserScope- projectInstalled <- isInstalled config n ProjectScope- let count = (if userInstalled then 1 else 0) + (if projectInstalled then 1 else 0) :: Int- when userInstalled $- case lookupItem n manifest of- Nothing -> pure ()- Just item -> doInstall config repoDir item UserScope- when projectInstalled $- case lookupItem n manifest of- Nothing -> pure ()- Just item -> doInstall config repoDir item ProjectScope- pure count- Text.IO.putStrLn $ "Updated " <> Text.pack (show updated) <> " item(s)."--isInstalled :: KitConfig -> Text -> KitScope -> IO Bool-isInstalled config n scope = do- safeName <- requireSafe "item name" (safeItemName n)- results <- forM (config ^. #providers) $ \provider -> do- providerBase <- providerAgentsBase config provider scope- skillExists <- doesDirectoryExist (skillTarget config provider providerBase safeName)- agentExists <- doesFileExist (agentTarget config provider providerBase safeName)- pure (skillExists || agentExists)- pure (or results)--removeIfDirectory :: FilePath -> IO Bool-removeIfDirectory dir = do- exists <- doesDirectoryExist dir- when exists (removeDirectoryRecursive dir)- pure exists--removeIfFile :: FilePath -> IO Bool-removeIfFile file = do- exists <- doesFileExist file- when exists (removeFile file)- pure exists--skillTarget :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath-skillTarget _config provider targetBase n =- targetBase </> skillTargetPath provider InteractiveProjectScope n--agentTarget :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath-agentTarget _config provider targetBase n =- targetBase </> agentTargetPath provider InteractiveProjectScope n--agentSidecarTarget :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath-agentSidecarTarget config provider targetBase n =- sidecarPath provider AgentKind (Text.pack n) targetBase (sidecarFileName config)--skillNameDesc :: SkillEntry -> (Text, Text)-skillNameDesc entry = (entry ^. #name, entry ^. #description)--agentNameDesc :: AgentEntry -> (Text, Text)-agentNameDesc entry = (entry ^. #name, entry ^. #description)--agentAsCodexToml :: AgentEntry -> Text -> Text-agentAsCodexToml entry body =- codexCustomAgentToml- CodexCustomAgent- { name = entry ^. #name,- description = entry ^. #description,- developerInstructions = stripYamlFrontmatter body- }--stripYamlFrontmatter :: Text -> Text-stripYamlFrontmatter input =- case map dropCr (Text.splitOn "\n" input) of- "---" : rest- | (_, _ : body) <- break (== "---") rest ->- Text.intercalate "\n" body- _ -> input- where- dropCr = Text.dropWhileEnd (== '\r')+-- | Reading the manifest and installing what it lists.+--+-- Every function here returns @'Either' 'KitError' a@ and prints+-- nothing: rendering and exiting belong to 'Baikai.Kit.Command.runKit'.+-- Internally the module raises 'KitError' and catches it at each+-- exported boundary, which keeps the plumbing readable without changing+-- what a caller observes.+module Baikai.Kit.Install+ ( InstallOptions (..),+ defaultInstallOptions,+ VisibilityCheck (..),+ checkVisibility,+ checkVisibilityWithEntries,+ relativeLinkTarget,+ loadManifest,+ loadManifestMaybe,+ lookupItem,+ installItem,+ installFrom,+ uninstallItem,+ renderUninstallReport,+ RemovalOutcome (..),+ OverwritePolicy (..),+ UpdateReport (..),+ updateKit,+ reinstallPresent,+ LocalEdits (..),+ checkLocalEdits,+ listAvailable,+ renderAvailable,+ PlannedWrite (..),+ WriteContent (..),+ executePlan,+ executePlanWith,+ stripYamlFrontmatter,+ )+where++import Baikai.AgentAssets+ ( AgentAssetProvider,+ CodexCustomAgent (..),+ agentTargetPath,+ codexCustomAgentToml,+ skillTargetPath,+ )+import Baikai.Interactive (InteractiveProvider (..), InteractiveScope (InteractiveProjectScope))+import Baikai.Kit.CodexConfig (addDisabledSkill, checkDisabledSkill, checkRemoveDisabledSkill, codexConfigPath, readSkillEntries, removeDisabledSkill)+import Baikai.Kit.Config (KitConfig, KitScope (..), kitCacheDir, providerAgentsBase, providerLabel, scopeLabel, sidecarFileName)+import Baikai.Kit.Error (KitError (..))+import Baikai.Kit.Link (SharedLink, applyLink, claudeLinks, foreignOwner, linkIsOurs, pathExists, preflightLink, relativeLinkTarget, removeLink)+import Baikai.Kit.Manifest+ ( AgentEntry,+ ItemSources,+ KitItem (..),+ KitItemKind (..),+ KitManifest (..),+ 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 (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 (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+ ( createDirectoryIfMissing,+ doesDirectoryExist,+ doesFileExist,+ removeDirectoryRecursive,+ 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+ { destination :: !FilePath,+ content :: !WriteContent+ }+ deriving stock (Generic, Show)++data WriteContent+ = CopyFrom !FilePath+ | WriteBytes !LBS.ByteString+ deriving stock (Show)++data RemovalOutcome = RemovalOutcome+ { provider :: !InteractiveProvider,+ skillRemoved :: !Bool,+ agentRemoved :: !Bool,+ sidecarRemoved :: !Bool,+ linksRemoved :: ![FilePath],+ configEntriesRemoved :: ![FilePath]+ }+ deriving stock (Eq, Generic, Show)++-- | What @kit update@ should do with an item whose installed files were+-- edited after they were installed.+data OverwritePolicy+ = KeepLocalEdits+ | OverwriteLocalEdits+ deriving stock (Eq, Show)++-- | What @kit update@ did, for the caller to render. @refresh@ is+-- 'Nothing' when no refresh was attempted, which is what+-- 'reinstallPresent' reports.+data UpdateReport = UpdateReport+ { refresh :: !(Maybe RepoRefresh),+ updated :: ![(Text, KitScope)],+ skipped :: ![(Text, KitScope)]+ }+ deriving stock (Eq, Generic, Show)++-- | Read and validate the manifest at @<dir>/kit.json@.+loadManifest :: FilePath -> IO (Either KitError KitManifest)+loadManifest = kitTry . loadManifestIO++-- | The same, tolerating an absent cache or an absent manifest. A+-- manifest that is present but unreadable is still a 'Left'.+loadManifestMaybe :: FilePath -> IO (Either KitError (Maybe KitManifest))+loadManifestMaybe "" = pure (Right Nothing)+loadManifestMaybe cacheDir = kitTry $ do+ exists <- doesFileExist (cacheDir </> "kit.json")+ if exists then Just <$> loadManifestIO cacheDir else pure Nothing++lookupItem :: Text -> KitManifest -> Maybe KitItem+lookupItem n manifest =+ case find (\entry -> entry ^. #name == n) (manifest ^. #skills) of+ Just skill -> Just (KitSkillItem skill)+ Nothing -> KitAgentItem <$> find (\entry -> entry ^. #name == n) (manifest ^. #agents)++-- | Refresh the cache, read the manifest and install one item. The+-- '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 -> 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 options++-- | The network-free half of 'installItem': install one item from a+-- manifest already read out of @repoDir@.+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.+uninstallItem :: KitConfig -> Text -> KitScope -> IO (Either KitError [RemovalOutcome])+uninstallItem config n scope = kitTry $ do+ safeName <- orThrow (KitUnsafeName n) (safeItemName n)+ forM (config ^. #providers) $ \provider -> do+ providerBase <- providerAgentsBase config provider scope+ 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 <- 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,+ linksRemoved = links,+ configEntriesRemoved = removedEntries+ }++renderUninstallReport :: Text -> KitScope -> [RemovalOutcome] -> Text+renderUninstallReport n scope outcomes+ | any assetRemoved outcomes =+ "Uninstalled "+ <> Text.intercalate "+" removedKinds+ <> " '"+ <> n+ <> "' from "+ <> scopeLabel scope+ <> " scope ("+ <> Text.intercalate "," removedProviders+ <> ")."+ <> visibilityRemovalNote+ | any (^. #sidecarRemoved) outcomes =+ "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]+ ++ ["agent" | any (^. #agentRemoved) outcomes]+ removedProviders = map (providerLabel . view #provider) (filter assetRemoved outcomes)++-- | Refresh the cache and reinstall the items that are already installed.+-- A failed refresh is an error here, unlike the other verbs: fetching+-- is what update is for.+updateKit :: KitConfig -> Maybe Text -> OverwritePolicy -> IO (Either KitError UpdateReport)+updateKit config mName policy = kitTry $ do+ cacheDir <- kitCacheDir config+ isCheckout <- doesDirectoryExist (cacheDir </> ".git")+ refreshed <-+ if isCheckout+ then do+ result <- pullKitRepo config cacheDir+ case result of+ PullSucceeded -> pure RepoPulled+ PullFailed err -> throwIO (KitPullFailed err)+ else do+ repo <- requireRepo config+ case repo ^. #refresh of+ RepoStale err -> throwIO (KitCloneFailed (config ^. #repoUrl) err)+ fresh -> pure fresh+ manifest <- loadManifestIO cacheDir+ report <- reinstallPresentIO config cacheDir manifest mName policy+ pure (report & #refresh .~ Just refreshed)++-- | The network-free second half of 'updateKit': reinstall what is+-- already installed from a manifest already read out of @repoDir@.+--+-- Under 'KeepLocalEdits' an item whose installed files no longer hash+-- to what its sidecar recorded is skipped rather than overwritten. A+-- sidecar written before those fields existed carries no hash, so such+-- an item is reinstalled without the check.+reinstallPresent ::+ KitConfig ->+ FilePath ->+ KitManifest ->+ Maybe Text ->+ OverwritePolicy ->+ IO (Either KitError UpdateReport)+reinstallPresent config repoDir manifest mName policy =+ kitTry (reinstallPresentIO config repoDir manifest mName policy)++-- | Refresh the cache and read the manifest of what the kit offers.+listAvailable :: KitConfig -> IO (Either KitError KitManifest)+listAvailable config = kitTry $ do+ repo <- requireRepo config+ loadManifestIO (repo ^. #dir)++-- | The listing @kit list@ prints, without a trailing newline.+renderAvailable :: KitManifest -> Text+renderAvailable manifest+ | null skills && null agents = "No items available in the kit."+ | otherwise = Text.intercalate "\n" (skillBlock ++ gap ++ agentBlock)+ where+ skills = manifest ^. #skills+ agents = manifest ^. #agents+ gap = ["" | not (null skills), not (null agents)]+ skillBlock+ | null skills = []+ | otherwise = "Skills:" : map (entryLine (columnWidth (map (view #name) skills)) . skillNameDesc) skills+ agentBlock+ | null agents = []+ | otherwise = "Agents:" : map (entryLine (columnWidth (map (view #name) agents)) . agentNameDesc) agents+ columnWidth names = maximum (map Text.length names)+ entryLine width (n, desc) = " " <> Text.justifyLeft (width + 2) ' ' n <> desc++-- | Put a plan in place, or leave the filesystem as it was.+--+-- Phase one writes every payload to a uniquely named temporary file in+-- its destination's directory. Phase two, per entry, moves an existing+-- destination aside to a unique backup and renames the temporary into+-- place, journalling both; a failure restores every completed entry in+-- reverse, removes the remaining temporaries and reports which paths+-- were restored and which could not be. Same-directory renames are+-- atomic on POSIX, so a reader sees either the old file or the new one.+executePlan :: [PlannedWrite] -> IO (Either KitError ())+executePlan = executePlanWith renameFile++-- | 'executePlan' with an injectable rename step. A test seam; not part+-- of the stable surface.+executePlanWith :: (FilePath -> FilePath -> IO ()) -> [PlannedWrite] -> IO (Either KitError ())+executePlanWith renameInto writes = do+ clash <- firstDirectoryDestination writes+ case clash of+ Just dest -> pure (Left (writeFailed ("destination is a directory: " <> Text.pack dest)))+ Nothing -> do+ staged <- stage [] writes+ case staged of+ Left err -> pure (Left err)+ Right temps -> commit [] temps+ where+ writeFailed reason = KitWriteFailed reason [] []++ firstDirectoryDestination [] = pure Nothing+ firstDirectoryDestination (planned : rest) = do+ let dest = planned ^. #destination+ isDir <- doesDirectoryExist dest+ if isDir then pure (Just dest) else firstDirectoryDestination rest++ -- Phase one. Nothing observable has changed while this runs, so a+ -- failure removes the temporaries and reports no changes.+ stage staged [] = pure (Right (reverse staged))+ stage staged (planned : rest) = do+ result <- try @IOException (stageOne planned)+ case result of+ Left e -> do+ removeTemps staged+ pure (Left (writeFailed (Text.pack (show e))))+ Right pair -> stage (pair : staged) rest++ stageOne planned = do+ let dest = planned ^. #destination+ dir = takeDirectory dest+ bytes <- payload (planned ^. #content)+ createDirectoryIfMissing True dir+ -- 'openTempFile' splits the template at its last extension, so the+ -- file is named <dest><random>.baikai-kit-tmp: unique, and still+ -- carrying the suffix a residue check looks for.+ (temp, handle) <- openTempFile dir (takeFileName dest <> ".baikai-kit-tmp")+ (LBS.hPut handle bytes >> hClose handle)+ `onException` (hClose handle >> ignoring (removeFile temp))+ pure (temp, dest)++ payload (CopyFrom src) = LBS.readFile src+ payload (WriteBytes bytes) = pure bytes++ -- Phase two. Every entry that changed anything is journalled with the+ -- backup it displaced and whether its rename completed.+ commit journal [] = do+ forM_ journal $ \(_, mBackup, _) -> forM_ mBackup (ignoring . removeFile)+ pure (Right ())+ commit journal (entry@(temp, dest) : rest) = do+ backed <- try @IOException (backupExisting dest)+ case backed of+ Left e -> unwind journal (entry : rest) e+ Right mBackup -> do+ renamed <- try @IOException (renameInto temp dest)+ case renamed of+ Left e -> unwind ((dest, mBackup, False) : journal) (entry : rest) e+ Right () -> commit ((dest, mBackup, True) : journal) rest++ backupExisting dest = do+ exists <- doesFileExist dest+ if not exists+ then pure Nothing+ else do+ (backup, handle) <- openTempFile (takeDirectory dest) (takeFileName dest <> ".baikai-kit-bak")+ hClose handle+ renameFile dest backup+ pure (Just backup)++ unwind journal remaining e = do+ outcomes <- forM (filter changedSomething journal) $ \(dest, mBackup, renamed) -> do+ restored <- try @IOException (restoreEntry dest mBackup renamed)+ pure (dest, either (const False) (const True) (restored :: Either IOException ()))+ removeTemps remaining+ pure . Left $+ KitWriteFailed+ (Text.pack (show e))+ [dest | (dest, True) <- outcomes]+ [dest | (dest, False) <- outcomes]++ changedSomething (_, mBackup, renamed) = renamed || has _Just mBackup++ restoreEntry dest (Just backup) _ = renameFile backup dest+ restoreEntry dest Nothing True = removeFile dest+ restoreEntry _ Nothing False = pure ()++ removeTemps entries = forM_ entries $ \(temp, _) -> ignoring (removeFile temp)++ ignoring action = do+ _ <- try @IOException action+ pure ()++-- Internal ------------------------------------------------------------++loadManifestIO :: FilePath -> IO KitManifest+loadManifestIO repoDir = do+ let manifestPath = repoDir </> "kit.json"+ exists <- doesFileExist manifestPath+ unless exists (throwIO (KitManifestMissing manifestPath))+ result <- eitherDecodeFileStrict' manifestPath+ case result of+ Left err -> throwIO (KitManifestInvalid manifestPath (Text.pack err))+ Right manifest -> do+ let declared = manifest ^. #version+ unless (declared `elem` supportedManifestVersions) $+ throwIO (KitManifestVersionUnsupported manifestPath declared)+ pure manifest++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+ choices <- prepareVisibility config item scope (Just options)+ doInstall config repoDir item scope choices+ pure item++requireRepo :: KitConfig -> IO KitRepo+requireRepo config = ensureKitRepo config >>= either throwIO pure++reinstallPresentIO ::+ KitConfig ->+ FilePath ->+ KitManifest ->+ Maybe Text ->+ OverwritePolicy ->+ IO UpdateReport+reinstallPresentIO config repoDir manifest mName policy = do+ results <-+ fmap concat . forM candidates $ \n ->+ fmap concat . forM [UserScope, ProjectScope] $ \scope -> do+ installed <- isInstalled config n scope+ case (installed, lookupItem n manifest) of+ (True, Just item) -> do+ modified <- case policy of+ OverwriteLocalEdits -> pure False+ KeepLocalEdits -> locallyModified config item scope+ choices <- prepareVisibility config item scope Nothing+ if modified+ then do+ repairSkippedVisibility choices+ pure [Left (n, scope)]+ else do+ doInstall config repoDir item scope choices+ pure [Right (n, scope)]+ _ -> pure []+ pure+ UpdateReport+ { refresh = Nothing,+ updated = [entry | Right entry <- results],+ skipped = [entry | Left entry <- results]+ }+ where+ candidates = case mName of+ Just n -> [n]+ Nothing ->+ map (view #name) (manifest ^. #skills)+ ++ map (view #name) (manifest ^. #agents)++-- | Whether one provider's installed copy of an item still matches what+-- was installed.+data LocalEdits+ = -- | Every recorded file reads back with the recorded hash.+ Unedited+ | -- | A recorded file differs, or cannot be read.+ Edited+ | -- | There is nothing to compare against: no sidecar, or a sidecar+ -- written before @installedFiles@ and @installedHash@ existed.+ EditsUnknown+ deriving stock (Eq, Show)++-- | Do one provider's installed files for an item still hash to what its+-- sidecar recorded? This is the one local-edit check: @kit update@ skips+-- an 'Edited' item under 'KeepLocalEdits', and @kit status@ reports it+-- as modified. Reads only local files.+checkLocalEdits ::+ KitConfig -> AgentAssetProvider -> KitScope -> KitItemKind -> Text -> IO (Either KitError LocalEdits)+checkLocalEdits config provider scope kind n = kitTry (localEditsIO config provider scope kind n)++localEditsIO :: KitConfig -> AgentAssetProvider -> KitScope -> KitItemKind -> Text -> IO LocalEdits+localEditsIO config provider scope kind n = do+ safeName <- orThrow (KitUnsafeName n) (safeItemName n)+ providerBase <- providerAgentsBase config provider scope+ let root = installedRoot config provider providerBase kind safeName+ mSidecar <- readSidecar (sidecarPath provider kind (Text.pack safeName) providerBase (sidecarFileName config))+ case mSidecar of+ Nothing -> pure EditsUnknown+ Just sidecar ->+ case (sidecar ^. #installedFiles, sidecar ^. #installedHash) of+ (Just recorded, Just expected) -> do+ entries <- forM recorded $ \rel -> do+ bytes <- try @IOException (BS.readFile (root </> Text.unpack rel))+ pure (either (const Nothing) (Just . (Text.unpack rel,)) (bytes :: Either IOException BS.ByteString))+ pure $ case sequence entries of+ Nothing -> Edited+ Just pairs+ | hashEntries pairs /= expected -> Edited+ | otherwise -> Unedited+ _ -> pure EditsUnknown++-- | Was any provider's copy of this item edited locally? A missing or+-- legacy sidecar is not an edit.+locallyModified :: KitConfig -> KitItem -> KitScope -> IO Bool+locallyModified config item scope = do+ checks <- forM (config ^. #providers) $ \provider ->+ localEditsIO config provider scope (kitItemKind item) (itemName item)+ pure (Edited `elem` checks)++-- | 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+ { relativeName :: !FilePath,+ target :: !FilePath,+ bytes :: !LBS.ByteString+ }+ deriving stock (Generic)++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+ contents <- forM resolved $ \(rel, path) -> do+ read' <- try @IOException (BS.readFile path)+ case read' of+ Left e -> throwIO (KitSourceUnreadable path (Text.pack (show e)))+ Right raw -> pure (rel, path, raw)+ let upstreamHash = hashEntries [(rel, raw) | (rel, _, raw) <- contents]+ fmap concat $+ 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+ (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+ ]+ sidecarWrite =+ PlannedWrite+ { destination =+ sidecarPath provider (kitItemKind item) (Text.pack safeName) targetBase (sidecarFileName config),+ content = WriteBytes (encode meta)+ }+ pure (assetWrites ++ [sidecarWrite])++-- | Turn an item's sources into what one provider gets. A skill's files+-- keep their names below the skill directory. An agent's first file+-- becomes the provider's agent file — copied for Claude, rendered to+-- TOML for Codex — and every remaining file goes into a resource+-- directory named after the agent beside it, because both providers'+-- agent directories are flat and a stray Markdown file there would be+-- discovered as a bogus agent.+providerAssets ::+ KitConfig ->+ InteractiveProvider ->+ FilePath ->+ FilePath ->+ KitItem ->+ [(FilePath, FilePath, BS.ByteString)] ->+ IO [PlannedAsset]+providerAssets config provider targetBase safeName (KitSkillItem _) contents =+ pure+ [ PlannedAsset+ { relativeName = rel,+ target = skillTarget config provider targetBase safeName </> rel,+ bytes = LBS.fromStrict raw+ }+ | (rel, _, raw) <- contents+ ]+providerAssets config provider targetBase safeName (KitAgentItem entry) contents =+ case contents of+ [] -> throwIO (KitItemHasNoFiles (entry ^. #name))+ (_, primaryPath, primaryRaw) : extras -> do+ let agentFile = agentTarget config provider targetBase safeName+ resourceDir = agentResourceDir config provider targetBase safeName+ body <- decodeSource primaryPath primaryRaw+ let primaryBytes = case provider of+ InteractiveClaude -> LBS.fromStrict primaryRaw+ InteractiveCodex -> LBS.fromStrict (Text.Encoding.encodeUtf8 (agentAsCodexToml entry body))+ pure $+ PlannedAsset+ { relativeName = takeFileName agentFile,+ target = agentFile,+ bytes = primaryBytes+ }+ : [ PlannedAsset+ { relativeName = safeName </> rel,+ target = resourceDir </> rel,+ bytes = LBS.fromStrict raw+ }+ | (rel, _, raw) <- extras+ ]++-- | Agent bodies are UTF-8, not whatever the locale says. (ADR 0007.)+decodeSource :: FilePath -> BS.ByteString -> IO Text+decodeSource path raw =+ case Text.Encoding.decodeUtf8' raw of+ Left err -> throwIO (KitSourceUnreadable path (Text.pack (show err)))+ Right text -> pure text++-- | Resolve every listed file through 'safeSourcePath', pairing the name+-- relative to the item's base with the absolute path to read.+resolveSources :: FilePath -> ItemSources -> IO [(FilePath, FilePath)]+resolveSources repoDir sources =+ forM (sources ^. #files) $ \rel -> do+ resolved <- safeSourcePath repoDir ((sources ^. #base) </> rel)+ (rel,) <$> either throwIO pure resolved++isInstalled :: KitConfig -> Text -> KitScope -> IO Bool+isInstalled config n scope = do+ safeName <- orThrow (KitUnsafeName n) (safeItemName n)+ results <- forM (config ^. #providers) $ \provider -> do+ providerBase <- providerAgentsBase config provider scope+ skillExists <- doesDirectoryExist (skillTarget config provider providerBase safeName)+ agentExists <- doesFileExist (agentTarget config provider providerBase safeName)+ 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+-- value. Only 'KitError' is caught; anything else propagates.+kitTry :: IO a -> IO (Either KitError a)+kitTry = try++orThrow :: (Text -> KitError) -> Either Text a -> IO a+orThrow toError = either (throwIO . toError) pure++removeIfDirectory :: FilePath -> IO Bool+removeIfDirectory dir = do+ exists <- doesDirectoryExist dir+ when exists (removeDirectoryRecursive dir)+ pure exists++removeIfFile :: FilePath -> IO Bool+removeIfFile file = do+ exists <- doesFileExist file+ when exists (removeFile file)+ pure exists++skillTarget :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath+skillTarget _config provider targetBase n =+ targetBase </> skillTargetPath provider InteractiveProjectScope n++agentTarget :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath+agentTarget _config provider targetBase n =+ targetBase </> agentTargetPath provider InteractiveProjectScope n++-- | Where a multi-file agent's extra files live: beside its agent file,+-- in a directory named after the agent.+agentResourceDir :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath+agentResourceDir config provider targetBase n =+ takeDirectory (agentTarget config provider targetBase n) </> n++-- | The directory the names in a sidecar's @installedFiles@ are relative+-- to, per provider and kind.+installedRoot :: KitConfig -> InteractiveProvider -> FilePath -> KitItemKind -> FilePath -> FilePath+installedRoot config provider targetBase SkillKind n = skillTarget config provider targetBase n+installedRoot config provider targetBase AgentKind n =+ takeDirectory (agentTarget config provider targetBase n)++agentSidecarTarget :: KitConfig -> InteractiveProvider -> FilePath -> FilePath -> FilePath+agentSidecarTarget config provider targetBase n =+ sidecarPath provider AgentKind (Text.pack n) targetBase (sidecarFileName config)++skillNameDesc :: SkillEntry -> (Text, Text)+skillNameDesc entry = (entry ^. #name, entry ^. #description)++agentNameDesc :: AgentEntry -> (Text, Text)+agentNameDesc entry = (entry ^. #name, entry ^. #description)++agentAsCodexToml :: AgentEntry -> Text -> Text+agentAsCodexToml entry body =+ codexCustomAgentToml+ CodexCustomAgent+ { name = entry ^. #name,+ description = entry ^. #description,+ developerInstructions = stripYamlFrontmatter body+ }++-- | Drop a leading YAML frontmatter block and normalise line endings to+-- LF. Every branch normalises, including input with no frontmatter and+-- input whose frontmatter is never closed.+stripYamlFrontmatter :: Text -> Text+stripYamlFrontmatter input =+ case textLines of+ firstLine : rest+ | isDelimiter firstLine,+ (_, _ : body) <- break isDelimiter rest ->+ Text.intercalate "\n" body+ _ -> Text.intercalate "\n" textLines+ where+ textLines = map (Text.dropWhileEnd (== '\r')) (Text.splitOn "\n" input)+ isDelimiter = (== "---") . Text.stripEnd
+ src/Baikai/Kit/Json.hs view
@@ -0,0 +1,147 @@+-- | The machine-readable documents @kit list --json@, @kit status --json@+-- and @kit update --json@ print, as explicit encoders.+--+-- These shapes are a public, versioned contract, pinned by golden tests+-- in @baikai-kit/test/golden/@. They are written here by hand rather+-- than derived, so renaming a Haskell field cannot change them. Every+-- document carries 'kitJsonFormatVersion' and a @document@ name. Adding+-- a key keeps the version; removing or renaming one, or changing what a+-- value means, increments it. Every documented key is always present,+-- with @null@ for an absent value.+--+-- See @docs/adr/0024-machine-readable-kit-output-is-a-versioned-contract.md@.+module Baikai.Kit.Json+ ( kitJsonFormatVersion,+ listDocument,+ statusDocument,+ updateDocument,+ )+where++import Baikai.Kit.Config (KitScope, providerLabel, scopeLabel)+import Baikai.Kit.Error (renderKitError)+import Baikai.Kit.Install (UpdateReport)+import Baikai.Kit.Manifest (KitItemKind (..), KitManifest, kindLabel)+import Baikai.Kit.Repo (RepoRefresh (..))+import Baikai.Kit.Status+ ( InstalledCopy,+ StatusReport,+ StatusRow,+ UpstreamAvailability (..),+ conditionLabel,+ )+import Baikai.Kit.Visibility (KitVisibility (..), visibilityLabel)+import Baikai.Prelude hiding ((.=))+import Data.Aeson (Value (Null), object, (.=))+import Data.List (sortOn)+import Data.Maybe (fromMaybe)++-- | The @formatVersion@ every document carries.+kitJsonFormatVersion :: Int+kitJsonFormatVersion = 1++-- | @kit-list@: what the kit offers, skills then agents in manifest order,+-- each with the copies installed of it (sorted user before project,+-- then by provider).+listDocument :: UpstreamAvailability -> KitManifest -> [InstalledCopy] -> Value+listDocument availability manifest copies =+ object+ [ "formatVersion" .= kitJsonFormatVersion,+ "document" .= ("kit-list" :: Text),+ "upstream" .= upstreamValue availability,+ "items"+ .= ( [ itemValue SkillKind (entry ^. #name) (entry ^. #description) (entry ^. #version) (visibilityLabel (fromMaybe ToolOnlyVisibility (entry ^. #visibility)))+ | entry <- manifest ^. #skills+ ]+ ++ [ itemValue AgentKind (entry ^. #name) (entry ^. #description) (entry ^. #version) (visibilityLabel (fromMaybe ToolOnlyVisibility (entry ^. #visibility)))+ | entry <- manifest ^. #agents+ ]+ )+ ]+ where+ itemValue :: KitItemKind -> Text -> Text -> Maybe Text -> Text -> Value+ itemValue kind n description version visibility =+ object+ [ "name" .= n,+ "kind" .= kindLabel kind,+ "description" .= description,+ "version" .= version,+ "visibility" .= visibility,+ "installed"+ .= map+ copyValue+ ( sortOn+ (\copy -> (copy ^. #scope, providerLabel (copy ^. #provider)))+ [copy | copy <- copies, copy ^. #name == n, copy ^. #kind == kind]+ )+ ]+ copyValue :: InstalledCopy -> Value+ copyValue copy =+ object+ [ "scope" .= scopeLabel (copy ^. #scope),+ "provider" .= providerLabel (copy ^. #provider),+ "version" .= (copy ^. #version),+ "path" .= (copy ^. #path)+ ]++-- | @kit-status@: one entry per installed copy — item, scope, and+-- provider — never aggregated, sorted by name, kind, scope, provider.+statusDocument :: StatusReport -> Value+statusDocument report =+ object+ [ "formatVersion" .= kitJsonFormatVersion,+ "document" .= ("kit-status" :: Text),+ "upstream" .= upstreamValue (report ^. #upstream),+ "items" .= map rowValue (sortOn rowKey (report ^. #rows))+ ]+ where+ rowKey row = (row ^. #name, row ^. #kind, row ^. #scope, row ^. #providers)+ rowValue :: StatusRow -> Value+ rowValue row =+ object+ [ "name" .= (row ^. #name),+ "kind" .= (row ^. #kind),+ "scope" .= (row ^. #scope),+ "provider" .= (row ^. #providers),+ "installedVersion" .= (row ^. #installedVersion),+ "latestVersion" .= (row ^. #latestVersion),+ "requestedVisibility" .= fmap visibilityLabel (row ^. #requestedVisibility),+ "effectiveVisibility" .= visibilityLabel (row ^. #effectiveVisibility),+ "conditions" .= map conditionLabel (row ^. #conditions),+ "upToDate" .= null (row ^. #conditions)+ ]++-- | @kit-update@: how the cache was refreshed and what was updated or+-- skipped, in the report's order.+updateDocument :: UpdateReport -> Value+updateDocument report =+ object+ [ "formatVersion" .= kitJsonFormatVersion,+ "document" .= ("kit-update" :: Text),+ "refresh" .= fmap refreshLabel (report ^. #refresh),+ "updated" .= map updatedValue (report ^. #updated),+ "skipped" .= map skippedValue (report ^. #skipped)+ ]+ where+ refreshLabel :: RepoRefresh -> Text+ refreshLabel = \case+ RepoCloned -> "cloned"+ RepoPulled -> "pulled"+ RepoStale _ -> "stale"+ updatedValue :: (Text, KitScope) -> Value+ updatedValue (n, scope) = object ["name" .= n, "scope" .= scopeLabel scope]+ -- 'UpdateReport' skips an item only for local edits today; the reason+ -- is spelled out so a later reason is an added value, not a new key.+ skippedValue :: (Text, KitScope) -> Value+ skippedValue (n, scope) =+ object+ [ "name" .= n,+ "scope" .= scopeLabel scope,+ "reason" .= ("locally-modified" :: Text)+ ]++upstreamValue :: UpstreamAvailability -> Value+upstreamValue = \case+ UpstreamReady -> object ["state" .= ("ready" :: Text), "detail" .= Null]+ UpstreamStale detail -> object ["state" .= ("stale" :: Text), "detail" .= detail]+ UpstreamUnavailable err -> object ["state" .= ("unavailable" :: Text), "detail" .= renderKitError err]
+ src/Baikai/Kit/Link.hs view
@@ -0,0 +1,129 @@+-- | Shared Claude names are aliases to tool-owned copies, never copies.+module Baikai.Kit.Link+ ( SharedLink (..),+ claudeLinks,+ linkIsOurs,+ preflightLink,+ applyLink,+ removeLink,+ foreignOwner,+ pathExists,+ relativeLinkTarget,+ )+where++import Baikai.AgentAssets (agentTargetPath, skillTargetPath)+import Baikai.Interactive (InteractiveProvider (InteractiveClaude), InteractiveScope (InteractiveProjectScope))+import Baikai.Kit.Config (KitConfig, KitScope (..), providerAgentsBase, sharedClaudeBase)+import Baikai.Kit.Error (KitError (..))+import Baikai.Kit.Manifest (KitItemKind (..))+import Baikai.Prelude+import Control.Exception (IOException, throwIO, try)+import Control.Monad (unless, when)+import Data.List (find, isPrefixOf, isSuffixOf)+import Data.Text qualified as Text+import System.Directory+ ( canonicalizePath,+ createDirectoryIfMissing,+ createDirectoryLink,+ createFileLink,+ doesDirectoryExist,+ doesPathExist,+ getSymbolicLinkTarget,+ listDirectory,+ makeAbsolute,+ pathIsSymbolicLink,+ removeFile,+ )+import System.FilePath (joinPath, splitDirectories, takeDirectory, (</>))++data SharedLink = SharedLink+ { location :: !FilePath,+ target :: !FilePath,+ directory :: !Bool,+ relative :: !Bool+ }+ deriving stock (Generic, Show)++claudeLinks :: KitConfig -> KitScope -> KitItemKind -> Text -> Bool -> IO [SharedLink]+claudeLinks config scope kind n resources = do+ shared <- sharedClaudeBase config scope >>= makeAbsolute+ owned <- providerAgentsBase config InteractiveClaude scope >>= makeAbsolute+ let name = Text.unpack n+ path = case kind of+ SkillKind -> skillTargetPath InteractiveClaude InteractiveProjectScope name+ AgentKind -> agentTargetPath InteractiveClaude InteractiveProjectScope name+ paths =+ (path, kind == SkillKind)+ : [(takeDirectory path </> name, True) | kind == AgentKind, resources]+ pure [SharedLink (shared </> p) (owned </> p) isDir (scope == ProjectScope) | (p, isDir) <- paths]++pathExists :: FilePath -> IO Bool+pathExists path = do+ exists <- doesPathExist path+ linked <- either (const False) id <$> try @IOException (pathIsSymbolicLink path)+ pure (exists || linked)++linkIsOurs :: SharedLink -> IO Bool+linkIsOurs link = do+ linked <- either (const False) id <$> try @IOException (pathIsSymbolicLink (link ^. #location))+ if not linked+ then pure False+ else do+ raw <- getSymbolicLinkTarget (link ^. #location)+ actual <- canonicalizePath (takeDirectory (link ^. #location) </> raw)+ expected <- canonicalizePath (link ^. #target)+ pure (actual == expected)++preflightLink :: SharedLink -> IO ()+preflightLink link = do+ exists <- pathExists (link ^. #location)+ ours <- linkIsOurs link+ when (exists && not ours) $ do+ owner <- foreignOwner (link ^. #location)+ throwIO (KitSharedNameTaken (link ^. #location) owner)++applyLink :: SharedLink -> IO ()+applyLink link = do+ preflightLink link+ ours <- linkIsOurs link+ unless ours $ do+ createDirectoryIfMissing True (takeDirectory (link ^. #location))+ let target =+ if link ^. #relative+ then relativeLinkTarget (takeDirectory (link ^. #location)) (link ^. #target)+ else link ^. #target+ (if link ^. #directory then createDirectoryLink else createFileLink) target (link ^. #location)++removeLink :: SharedLink -> IO Bool+removeLink link = do+ ours <- linkIsOurs link+ when ours (removeFile (link ^. #location))+ pure ours++-- | A lexical relative target, including sibling directories.+relativeLinkTarget :: FilePath -> FilePath -> FilePath+relativeLinkTarget parent target = go (splitDirectories parent) (splitDirectories target)+ where+ go (a : as) (b : bs) | a == b = go as bs+ go as bs = joinPath (replicate (length as) ".." ++ bs)++foreignOwner :: FilePath -> IO (Maybe Text)+foreignOwner path = do+ linked <- either (const False) id <$> try @IOException (pathIsSymbolicLink path)+ if linked+ then do+ raw <- getSymbolicLinkTarget path+ absolute <- canonicalizePath (takeDirectory path </> raw)+ pure (ownerOf (splitDirectories absolute))+ else do+ isDir <- doesDirectoryExist path+ files <- if isDir then listDirectory path else pure []+ pure $ Text.pack . drop 1 . takeWhileSuffix <$> find sidecar files+ where+ sidecar f = "." `isPrefixOf` f && "-kit.json" `isSuffixOf` f+ takeWhileSuffix f = take (length f - length ("-kit.json" :: String)) f+ ownerOf (".config" : tool : "agents" : _) = Just (Text.pack tool)+ ownerOf (tool : "agents" : _) | "." `isPrefixOf` tool = Just (Text.pack (drop 1 tool))+ ownerOf (_ : rest) = ownerOf rest+ ownerOf [] = Nothing
src/Baikai/Kit/Manifest.hs view
@@ -1,21 +1,27 @@ module Baikai.Kit.Manifest ( AgentEntry (..),+ ItemSources (..), KitItem (..), KitItemKind (..), KitManifest (..), SkillEntry (..),- agentSources, itemKind, itemName,+ itemSources, itemVersion,+ itemVisibility, kitItemKind, kindLabel,+ supportedManifestVersions, ) where +import Baikai.Kit.Error (KitError (..))+import Baikai.Kit.Path (safeItemName, safeRelativePath)+import Baikai.Kit.Visibility (KitVisibility (..)) import Baikai.Prelude-import Data.Text qualified as Text-import System.FilePath (takeFileName, (</>))+import Data.Bifunctor (first)+import System.FilePath (takeDirectory, takeFileName) data KitManifest = KitManifest { version :: !Int,@@ -30,7 +36,8 @@ description :: !Text, version :: !(Maybe Text), path :: !Text,- files :: ![Text]+ files :: ![Text],+ visibility :: !(Maybe KitVisibility) } deriving stock (Generic, Show) deriving anyclass (FromJSON)@@ -40,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)@@ -55,14 +63,50 @@ | AgentKind deriving stock (Eq, Ord, Show) -agentSources :: AgentEntry -> [(FilePath, FilePath)]-agentSources entry =- case entry ^. #files of- Just fs -> [(Text.unpack (entry ^. #path) </> Text.unpack f, Text.unpack f) | f <- fs]- Nothing ->- let source = Text.unpack (entry ^. #path)- in [(source, takeFileName source)]+-- | The manifest versions this installer understands. Both decode+-- identically today; a future version with different semantics must be+-- refused rather than misinstalled.+supportedManifestVersions :: [Int]+supportedManifestVersions = [1, 2] +-- | Where an item's files live inside the kit checkout: a directory+-- relative to the checkout and file names relative to that directory+-- (the first is the agent body for agents).+--+-- Lexically validated; physical checks are+-- 'Baikai.Kit.Path.safeSourcePath'.+data ItemSources = ItemSources+ { base :: !FilePath,+ files :: ![FilePath]+ }+ deriving stock (Eq, Generic, Show)++-- | Derive an item's source list. Fails with 'KitUnsafeName',+-- 'KitUnsafePath' or 'KitItemHasNoFiles'. The item name is validated+-- here too, so every consumer of the result has already seen it pass.+itemSources :: KitItem -> Either KitError ItemSources+itemSources item = do+ _ <- first (KitUnsafeName (itemName item)) (safeItemName (itemName item))+ case item of+ KitSkillItem entry -> do+ base <- lexPath (entry ^. #path)+ files <- traverse lexPath (entry ^. #files)+ nonEmpty ItemSources {base, files}+ KitAgentItem entry ->+ case entry ^. #files of+ Just declared -> do+ base <- lexPath (entry ^. #path)+ files <- traverse lexPath declared+ nonEmpty ItemSources {base, files}+ Nothing -> do+ source <- lexPath (entry ^. #path)+ pure ItemSources {base = takeDirectory source, files = [takeFileName source]}+ where+ lexPath raw = first (KitUnsafePath raw) (safeRelativePath raw)+ nonEmpty sources+ | null (sources ^. #files) = Left (KitItemHasNoFiles (itemName item))+ | otherwise = Right sources+ itemName :: KitItem -> Text itemName (KitSkillItem entry) = entry ^. #name itemName (KitAgentItem entry) = entry ^. #name@@ -82,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/Path.hs view
@@ -1,12 +1,16 @@ module Baikai.Kit.Path ( safeItemName, safeRelativePath,- safeUnder,+ safeSourcePath, ) where +import Baikai.Kit.Error (KitError (..)) import Baikai.Prelude+import Control.Exception (IOException, try)+import Data.List (isPrefixOf) import Data.Text qualified as Text+import System.Directory (canonicalizePath, doesFileExist, doesPathExist, pathIsSymbolicLink) import System.FilePath (isAbsolute, normalise, splitDirectories, (</>)) safeRelativePath :: Text -> Either Text FilePath@@ -36,5 +40,58 @@ | length (splitDirectories name) /= 1 = Left "name must be a single path segment" | otherwise = Right name -safeUnder :: FilePath -> Text -> Either Text FilePath-safeUnder base rel = (base </>) <$> safeRelativePath rel+-- | Resolve an untrusted relative source path below a trusted kit+-- checkout.+--+-- Runs 'safeRelativePath' on the relative path, then walks every prefix+-- of it below @root@: a prefix that does not exist is+-- 'KitSourceMissing', a prefix that is a symbolic link+-- ('pathIsSymbolicLink') is 'KitSourceSymlink'. Finally the canonical+-- form of the full path must lie component-wise below the canonical+-- form of @root@ ('canonicalizePath' on both, compared with+-- 'splitDirectories'), else 'KitSourceEscapes', and the full path must+-- be a regular file ('doesFileExist'), else 'KitSourceMissing'. Any+-- 'IOException' raised while inspecting a prefix is+-- 'KitSourceUnreadable'. Returns @root '</>' rel@.+--+-- Check-then-read: the caller reads the returned path afterwards; a+-- writer to the checkout could swap a file for a link in between, which+-- is accepted because the checkout is owned by the invoking user and+-- written only by git before this runs.+safeSourcePath :: FilePath -> FilePath -> IO (Either KitError FilePath)+safeSourcePath root rel =+ case safeRelativePath (Text.pack rel) of+ Left reason -> pure (Left (KitUnsafePath (Text.pack rel) reason))+ Right validated -> do+ let full = root </> validated+ components = filter (/= ".") (splitDirectories validated)+ outcome <- try @IOException (inspect root full components)+ pure (either (Left . KitSourceUnreadable full . Text.pack . show) id outcome)+ where+ inspect base full components = do+ walked <- walkPrefixes base components+ case walked of+ Just err -> pure (Left err)+ Nothing -> do+ canonRoot <- canonicalizePath base+ canonFull <- canonicalizePath full+ if not (splitDirectories canonRoot `isPrefixOf` splitDirectories canonFull)+ then pure (Left (KitSourceEscapes canonFull canonRoot))+ else do+ isFile <- doesFileExist full+ pure (if isFile then Right full else Left (KitSourceMissing full))++ walkPrefixes _ [] = pure Nothing+ walkPrefixes base (component : rest) = do+ let here = base </> component+ -- 'doesPathExist' is False for a dangling link, which refuses the+ -- path without following it; 'pathIsSymbolicLink' throws when the+ -- path is absent, so the existence check comes first.+ exists <- doesPathExist here+ if not exists+ then pure (Just (KitSourceMissing here))+ else do+ isLink <- pathIsSymbolicLink here+ if isLink+ then pure (Just (KitSourceSymlink here))+ else walkPrefixes here rest
src/Baikai/Kit/Repo.hs view
@@ -1,19 +1,20 @@ module Baikai.Kit.Repo- ( ensureKitRepo,+ ( KitRepo (..),+ RepoRefresh (..),+ ensureKitRepo, PullResult (..), pullKitRepo, ) where import Baikai.Kit.Config (KitConfig, kitCacheDir)+import Baikai.Kit.Error (KitError (..)) import Baikai.Prelude import Control.Exception (IOException, try) import Data.Text qualified as Text-import Data.Text.IO qualified as Text.IO import System.Directory (createDirectoryIfMissing, doesDirectoryExist, doesFileExist)-import System.Exit (ExitCode (..), exitFailure)+import System.Exit (ExitCode (..)) import System.FilePath ((</>))-import System.IO (hPutStrLn, stderr) import System.Process (readProcessWithExitCode) data PullResult@@ -21,37 +22,58 @@ | PullFailed !Text deriving stock (Eq, Show) -ensureKitRepo :: KitConfig -> IO FilePath+-- | What happened to the cache on the way to returning it.+data RepoRefresh+ = -- | The cache did not exist and was cloned.+ RepoCloned+ | -- | The cache existed and was pulled.+ RepoPulled+ | -- | The refresh failed and the cached copy is being used as it is;+ -- the 'Text' is git's output. A caller that needs fresh content+ -- (@kit update@) treats this as a failure; the others warn.+ RepoStale !Text+ deriving stock (Eq, Show)++data KitRepo = KitRepo+ { dir :: !FilePath,+ refresh :: !RepoRefresh+ }+ deriving stock (Eq, Generic, Show)++-- | Resolve the kit cache, refreshing it if it is already a checkout and+-- cloning it if it is not. Never prints and never exits: a first clone+-- that fails with no usable cache is 'KitCloneFailed', and a failed+-- refresh over a usable cache is 'RepoStale'.+ensureKitRepo :: KitConfig -> IO (Either KitError KitRepo) ensureKitRepo config = do cacheDir <- kitCacheDir config exists <- doesDirectoryExist (cacheDir </> ".git") if exists then do result <- pullKitRepo config cacheDir- case result of- PullSucceeded -> pure ()- PullFailed err ->- hPutStrLn stderr $ "Warning: git pull failed, using cached data. " <> Text.unpack err- pure cacheDir+ pure . Right $ case result of+ PullSucceeded -> KitRepo {dir = cacheDir, refresh = RepoPulled}+ PullFailed err -> KitRepo {dir = cacheDir, refresh = RepoStale err} else do- Text.IO.putStrLn $ "Fetching " <> (config ^. #toolName) <> "-kit..."- createDirectoryIfMissing True cacheDir- (exitCode, _, errOut) <-- readProcessWithExitCode- "git"- ["clone", "--depth", "1", Text.unpack (config ^. #repoUrl), cacheDir]- ""- case exitCode of- ExitSuccess -> pure cacheDir- ExitFailure _ -> do- manifestExists <- doesFileExist (cacheDir </> "kit.json")- if manifestExists- then do- hPutStrLn stderr $ "Warning: git clone failed, using cached data. " <> errOut- pure cacheDir- else do- hPutStrLn stderr $ "Error: Failed to fetch kit repository: " <> errOut- exitFailure+ cloned <-+ try @IOException $ do+ createDirectoryIfMissing True cacheDir+ readProcessWithExitCode+ "git"+ ["clone", "--depth", "1", Text.unpack (config ^. #repoUrl), cacheDir]+ ""+ case cloned of+ -- A missing `git` binary arrives here as an IOException.+ Left e -> staleOrFailed cacheDir (Text.pack (show e))+ Right (ExitSuccess, _, _) -> pure (Right KitRepo {dir = cacheDir, refresh = RepoCloned})+ Right (ExitFailure _, _, errOut) -> staleOrFailed cacheDir (Text.pack errOut)+ where+ staleOrFailed cacheDir output = do+ manifestExists <- doesFileExist (cacheDir </> "kit.json")+ pure $+ if manifestExists+ then Right KitRepo {dir = cacheDir, refresh = RepoStale output}+ else Left (KitCloneFailed (config ^. #repoUrl) output) pullKitRepo :: KitConfig -> FilePath -> IO PullResult pullKitRepo _config cacheDir = do
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
@@ -1,58 +1,97 @@ module Baikai.Kit.Sidecar ( SidecarMeta (..), computeKitHash,+ hashEntries, newSidecarMeta, sidecarPath, readSidecar,- writeSidecar, ) where import Baikai.AgentAssets (AgentAssetProvider, agentTargetPath, skillTargetPath) import Baikai.Interactive (InteractiveScope (InteractiveProjectScope))-import Baikai.Kit.Manifest (KitItem, KitItemKind (..), itemKind, itemName, itemVersion, kitItemKind)-import Baikai.Kit.Path (safeRelativePath)+import Baikai.Kit.Error (KitError (..))+import Baikai.Kit.Manifest (KitItem, KitItemKind (..), itemKind, itemName, itemVersion)+import Baikai.Kit.Path (safeSourcePath) import Baikai.Prelude+import Control.Exception (IOException, try) import Crypto.Hash (Digest, SHA256) import Crypto.Hash qualified as Hash-import Data.Aeson (eitherDecodeFileStrict', encode)+import Data.Aeson (eitherDecodeFileStrict') import Data.Binary.Put (putWord64be, runPut) import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as LBS-import Data.List (sort)+import Data.List (sortOn) import Data.Text qualified as Text import Data.Text.Encoding qualified as Text.Encoding import Data.Time.Clock (getCurrentTime) import Data.Time.Format (defaultTimeLocale, formatTime)-import System.Directory (createDirectoryIfMissing, doesFileExist)-import System.FilePath (dropExtension, takeDirectory, (</>))+import System.Directory (doesFileExist)+import System.FilePath (dropExtension, (</>)) import System.IO (hPutStrLn, stderr) +-- | The metadata written beside each installed asset.+--+-- @installedFiles@ and @installedHash@ describe what this tool wrote+-- for one provider — the file names relative to that provider's target+-- directory, and the hash of exactly those bytes — so @kit update@ can+-- tell a file the user edited from one it installed. Both are 'Nothing'+-- in sidecars written before those fields existed, and such an item is+-- updated without the check. data SidecarMeta = SidecarMeta { name :: !Text, kind :: !Text, version :: !(Maybe Text), hash :: !Text,- installedAt :: !Text+ installedAt :: !Text,+ installedFiles :: !(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) -computeKitHash :: FilePath -> [Text] -> IO Text-computeKitHash baseDir relFiles = do- chunks <- mapM (readOne baseDir) (sort relFiles)- let digest = Hash.hash (BS.concat chunks) :: Digest SHA256- hex = Text.pack (show digest)- pure ("sha256:" <> hex)+-- | Content hash of the listed files, which lie at @base '</>' file@+-- below the kit checkout @root@.+--+-- The hashed bytes are, per file sorted by name: the file name relative+-- to @base@, NUL, the big-endian length, the content, NUL — unchanged+-- from earlier releases, so existing sidecars keep matching. Every file+-- is resolved through 'safeSourcePath' first, so a symbolic link+-- anywhere below @root@ refuses the hash instead of being read through.+computeKitHash :: FilePath -> FilePath -> [FilePath] -> IO (Either KitError Text)+computeKitHash root base relFiles = do+ results <- traverse readOne relFiles+ pure (hashEntries <$> sequence results) where- readOne :: FilePath -> Text -> IO BS.ByteString- readOne dir rel = do- safeRel <- either (ioError . userError . Text.unpack) pure (safeRelativePath rel)- content <- BS.readFile (dir </> safeRel)- let pathBytes = Text.Encoding.encodeUtf8 (Text.pack safeRel)- lenBytes = LBS.toStrict (runPut (putWord64be (fromIntegral (BS.length content))))- pure $ BS.concat [pathBytes, BS.singleton 0x00, lenBytes, content, BS.singleton 0x00]+ readOne :: FilePath -> IO (Either KitError (FilePath, BS.ByteString))+ readOne rel = do+ resolved <- safeSourcePath root (base </> rel)+ case resolved of+ Left err -> pure (Left err)+ Right path -> do+ content <- try @IOException (BS.readFile path)+ pure $ case content of+ Left e -> Left (KitSourceUnreadable path (Text.pack (show e)))+ Right bytes -> Right (rel, bytes) +-- | The pure core: hash already-read (relative name, bytes) pairs.+hashEntries :: [(FilePath, BS.ByteString)] -> Text+hashEntries entries = "sha256:" <> Text.pack (show digest)+ where+ digest = Hash.hash (BS.concat (map chunk (sortOn fst entries))) :: Digest SHA256+ chunk (rel, content) =+ BS.concat+ [ Text.Encoding.encodeUtf8 (Text.pack rel),+ BS.singleton 0x00,+ LBS.toStrict (runPut (putWord64be (fromIntegral (BS.length content)))),+ content,+ BS.singleton 0x00+ ]+ sidecarPath :: AgentAssetProvider -> KitItemKind -> Text -> FilePath -> Text -> FilePath sidecarPath provider SkillKind itemName' targetBase sidecarName = targetBase@@ -76,15 +115,11 @@ hPutStrLn stderr $ "Warning: failed to parse sidecar " <> p <> ": " <> err pure Nothing -writeSidecar :: AgentAssetProvider -> KitItem -> FilePath -> Text -> Text -> IO ()-writeSidecar provider item targetBase sidecarName hashStr = do- meta <- newSidecarMeta item hashStr- let out = sidecarPath provider (kitItemKind item) (itemName item) targetBase sidecarName- createDirectoryIfMissing True (takeDirectory out)- LBS.writeFile out (encode meta)--newSidecarMeta :: KitItem -> Text -> IO SidecarMeta-newSidecarMeta item hashStr = do+-- | 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 -> 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@@ -93,5 +128,11 @@ kind = itemKind item, version = itemVersion item, hash = hashStr,- installedAt = stamp+ installedAt = stamp,+ installedFiles = Just writtenFiles,+ installedHash = Just writtenHash,+ visibility,+ visibilitySource,+ sharedLinks = Just sharedLinks,+ codexDisabledSkills = Just codexDisabledSkills }
src/Baikai/Kit/Status.hs view
@@ -1,39 +1,82 @@ module Baikai.Kit.Status- ( KitState (..),+ ( KitCondition (..),+ InstalledCopy (..),+ StatusReport (..), StatusRow (..),+ UpstreamAvailability (..), classify, collectStatus,+ installedCopies, kitStatus,- renderState,+ conditionLabel,+ renderConditions,+ renderStatusTable, ) where import Baikai.AgentAssets (AgentAssetProvider, agentTargetPath, skillTargetPath)-import Baikai.Interactive (InteractiveScope (InteractiveProjectScope))+import Baikai.Interactive (InteractiveProvider (InteractiveCodex), InteractiveScope (InteractiveProjectScope))+import Baikai.Kit.CodexConfig (codexConfigPath, readSkillEntries) import Baikai.Kit.Config (KitConfig, KitScope (..), providerAgentsBase, providerLabel, sidecarFileName)-import Baikai.Kit.Install (loadManifestMaybe, lookupItem)-import Baikai.Kit.Manifest (AgentEntry, KitItem (..), KitItemKind (..), agentSources, itemKind, itemVersion, kindLabel)-import Baikai.Kit.Repo (ensureKitRepo)+import Baikai.Kit.Error (KitError (..))+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.Exception (IOException, try) import Control.Monad (forM) import Data.List (groupBy, isPrefixOf, isSuffixOf, nub, sort, sortOn)-import Data.Maybe (fromMaybe)+import Data.Maybe (fromMaybe, isNothing) import Data.Text qualified as Text-import Data.Text.IO qualified as Text.IO-import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)+import System.Directory (doesDirectoryExist, listDirectory) import System.FilePath (takeDirectory, (</>)) -data KitState- = KitUpToDate- | KitOutdated- | KitDirty- | KitDirtyOutdated- | KitDelisted- | KitUnknown- deriving stock (Eq, Ord, Show)+-- | One thing @kit status@ can say about an installed copy. A row carries+-- a sorted, duplicate-free list of these; an empty list means the copy+-- is up to date. The order of the constructors is the order the labels+-- are rendered in.+data KitCondition+ = -- | @unknown@: no readable sidecar, so nothing can be compared.+ KitUnknown+ | -- | @delisted@: the manifest no longer lists the item.+ KitDelisted+ | -- | @refused@: the upstream lists a source the installer refuses — a+ -- symbolic link, or a path outside the kit.+ KitUpstreamRefused+ | -- | @outdated@: the manifest version differs from the installed one.+ KitOutdated+ | -- | @changed-upstream@: the upstream sources changed since install+ -- without a version change. @kit update@ reinstalls it.+ KitChangedUpstream+ | -- | @modified@: installed files were edited since install. @kit update@+ -- skips it unless @--force@.+ KitLocallyModified+ | -- | @edits-unknown@: the sidecar predates the installed-file hash, so+ -- local edits cannot be detected.+ KitLocalEditsUnknown+ | KitVisibilityBroken+ deriving stock (Eq, Ord, Show, Enum, Bounded) +-- | Whether the cached upstream could be consulted for this report.+data UpstreamAvailability+ = -- | The cache was refreshed and its manifest read.+ UpstreamReady+ | -- | The cache could not be refreshed; the rows compare against the+ -- copy already on disk. The 'Text' is git's output.+ UpstreamStale !Text+ | -- | There is no usable cache: the rows say what is installed and+ -- nothing about how it compares.+ UpstreamUnavailable !KitError+ deriving stock (Eq, Show)++-- | What @kit status@ found, for the caller to render.+data StatusReport = StatusReport+ { upstream :: !UpstreamAvailability,+ rows :: ![StatusRow]+ }+ deriving stock (Eq, Generic, Show)+ data StatusRow = StatusRow { name :: !Text, kind :: !Text,@@ -41,51 +84,101 @@ providers :: !Text, installedVersion :: !(Maybe Text), latestVersion :: !(Maybe Text),- state :: !KitState+ -- | Sorted and duplicate-free; empty means up to date.+ conditions :: ![KitCondition],+ requestedVisibility :: !(Maybe KitVisibility),+ effectiveVisibility :: !KitVisibility } deriving stock (Eq, Generic, Show) -renderState :: KitState -> Text-renderState = \case- KitUpToDate -> "up-to-date"- KitOutdated -> "outdated"- KitDirty -> "dirty"- KitDirtyOutdated -> "dirty+outdated"- KitDelisted -> "delisted"+-- | The stable spelling of a condition, shared by the status table and any+-- machine-readable output.+conditionLabel :: KitCondition -> Text+conditionLabel = \case KitUnknown -> "unknown"+ KitDelisted -> "delisted"+ KitUpstreamRefused -> "refused"+ KitOutdated -> "outdated"+ KitChangedUpstream -> "changed-upstream"+ KitLocallyModified -> "modified"+ KitLocalEditsUnknown -> "edits-unknown"+ KitVisibilityBroken -> "visibility-broken" -classify :: Maybe SidecarMeta -> Maybe KitItem -> Maybe Text -> KitState-classify Nothing _ _ = KitUnknown-classify (Just _) Nothing _ = KitDelisted+-- | @up-to-date@ for no conditions; otherwise the labels in constructor+-- order joined with @+@, e.g. @outdated+changed-upstream+modified@.+renderConditions :: [KitCondition] -> Text+renderConditions [] = "up-to-date"+renderConditions conds = Text.intercalate "+" (map conditionLabel (sort (nub conds)))++-- | The conditions that compare an installed copy with the upstream:+-- whether it is known, listed, outdated, or changed upstream. Local+-- edits are checked separately, by 'checkLocalEdits'.+classify :: Maybe SidecarMeta -> Maybe KitItem -> Maybe Text -> [KitCondition]+classify Nothing _ _ = [KitUnknown]+classify (Just _) Nothing _ = [KitDelisted] classify (Just sm) (Just it) mUpstreamHash = let outdated = case itemVersion it of Just latest -> sm ^. #version /= Just latest Nothing -> False- dirty = case mUpstreamHash of+ changed = case mUpstreamHash of Just up -> up /= sm ^. #hash Nothing -> False- in case (outdated, dirty) of- (True, True) -> KitDirtyOutdated- (True, False) -> KitOutdated- (False, True) -> KitDirty- (False, False) -> KitUpToDate+ in [KitOutdated | outdated] ++ [KitChangedUpstream | changed] -kitStatus :: KitConfig -> IO ()+-- | Collect the status of everything installed. Needs no network: a kit+-- repository that cannot be reached is reported as+-- 'UpstreamUnavailable' and the installed rows are still returned.+kitStatus :: KitConfig -> IO StatusReport kitStatus config = do- cacheDir <- resolveCacheOrEmpty config- rows <- collectStatus config cacheDir [(UserScope, "user"), (ProjectScope, "project")]- renderStatusTable rows+ repo <- ensureKitRepo config+ case repo of+ Left err -> report (UpstreamUnavailable err) ""+ Right resolved -> do+ let availability = case resolved ^. #refresh of+ RepoStale err -> UpstreamStale err+ RepoCloned -> UpstreamReady+ RepoPulled -> UpstreamReady+ manifest <- loadManifestMaybe (resolved ^. #dir)+ case manifest of+ Left err -> report (UpstreamUnavailable err) ""+ Right Nothing -> report availability ""+ Right (Just _) -> report availability (resolved ^. #dir)+ where+ report upstream cacheDir = do+ rows <- collectStatus config cacheDir [(UserScope, "user"), (ProjectScope, "project")]+ pure StatusReport {upstream, rows} collectStatus :: KitConfig -> FilePath -> [(KitScope, Text)] -> IO [StatusRow] collectStatus config cacheDir scopes = do- mManifest <- loadManifestMaybe cacheDir+ -- A manifest that cannot be read is treated here as no manifest; the+ -- report as a whole says so through 'UpstreamUnavailable'.+ mManifest <- either (const Nothing) id <$> loadManifestMaybe cacheDir+ entries <-+ if InteractiveCodex `elem` (config ^. #providers)+ then codexConfigPath >>= readSkillEntries+ else pure (Right []) fmap concat . forM scopes $ \(scope, scopeText) -> do items <- scanInstalled config scope forM items $ \(provider, baseDir, itemName', scannedKind) -> do let mItem = lookupItem itemName' =<< mManifest mSidecar <- readSidecar (sidecarPath provider scannedKind itemName' baseDir (sidecarFileName config))- mUpstreamHash <- upstreamHash cacheDir mItem- let state' = classify mSidecar mItem mUpstreamHash+ upstream <- upstreamHash cacheDir mItem+ let upstreamConditions = case upstream of+ Left _ -> KitUpstreamRefused : [KitUnknown | isNothing mSidecar]+ Right mUpstreamHash -> classify mSidecar mItem mUpstreamHash+ localConditions <- case mSidecar of+ Nothing -> pure []+ Just _ -> do+ edits <- checkLocalEdits config provider scope scannedKind itemName'+ pure $ case edits of+ Right Unedited -> []+ Right Edited -> [KitLocallyModified]+ Right EditsUnknown -> [KitLocalEditsUnknown]+ Left _ -> [KitLocalEditsUnknown]+ visibility <- checkVisibilityWithEntries entries config provider scope scannedKind itemName'+ let requestedVisibility = either (const Nothing) (view #requested) visibility+ effectiveVisibility = either (const SharedVisibility) (view #effective) visibility+ visibilityConditions = [KitVisibilityBroken | either (const True) (not . null . view #broken) visibility] pure StatusRow { name = itemName',@@ -94,32 +187,64 @@ providers = providerLabel provider, installedVersion = mSidecar >>= (^. #version), latestVersion = mItem >>= itemVersion,- state = state'+ conditions = sort (nub (upstreamConditions ++ localConditions ++ visibilityConditions)),+ requestedVisibility,+ effectiveVisibility } -resolveCacheOrEmpty :: KitConfig -> IO FilePath-resolveCacheOrEmpty config = do- result <- try @IOException (ensureKitRepo config)- case result of- Right dir -> do- manifestExists <- doesFileExist (dir </> "kit.json")- pure (if manifestExists then dir else "")- Left _ -> pure ""+-- | One item at one scope for one provider, and where it is.+data InstalledCopy = InstalledCopy+ { name :: !Text,+ kind :: !KitItemKind,+ scope :: !KitScope,+ provider :: !AgentAssetProvider,+ -- | The skill directory or the agent file.+ path :: !FilePath,+ -- | From the sidecar; 'Nothing' without a readable one.+ version :: !(Maybe Text)+ }+ deriving stock (Eq, Generic, Show) -upstreamHash :: FilePath -> Maybe KitItem -> IO (Maybe Text)-upstreamHash "" _ = pure Nothing-upstreamHash _ Nothing = pure Nothing-upstreamHash cacheDir (Just (KitSkillItem entry)) =- tryHash (cacheDir </> Text.unpack (entry ^. #path)) (entry ^. #files)-upstreamHash cacheDir (Just (KitAgentItem entry)) =- tryHash (agentSourceBase cacheDir entry) (map (Text.pack . snd) (agentSources entry))+-- | Every installed copy at user and project scope, in filesystem order.+-- Reads only local files.+installedCopies :: KitConfig -> IO [InstalledCopy]+installedCopies config =+ fmap concat . forM [UserScope, ProjectScope] $ \scope -> do+ items <- scanInstalled config scope+ forM items $ \(provider, baseDir, itemName', scannedKind) -> do+ mSidecar <- readSidecar (sidecarPath provider scannedKind itemName' baseDir (sidecarFileName config))+ let relative = case scannedKind of+ SkillKind -> skillTargetPath provider InteractiveProjectScope (Text.unpack itemName')+ AgentKind -> agentTargetPath provider InteractiveProjectScope (Text.unpack itemName')+ pure+ InstalledCopy+ { name = itemName',+ kind = scannedKind,+ scope,+ provider,+ path = baseDir </> relative,+ version = mSidecar >>= (^. #version)+ } -tryHash :: FilePath -> [Text] -> IO (Maybe Text)-tryHash base files = do- result <- try @IOException (computeKitHash base files)- case result of- Right h -> pure (Just h)- Left _ -> pure Nothing+-- | The hash of an item's sources as they are in the cached checkout.+--+-- @Right Nothing@ means there is nothing to compare against: no cache,+-- no manifest entry, or an upstream source that is simply gone. A+-- 'Left' means the upstream listing is one this installer refuses to+-- read — a symbolic link, an escaping path, an unsafe manifest string —+-- which 'collectStatus' shows as 'KitUpstreamRefused'.+upstreamHash :: FilePath -> Maybe KitItem -> IO (Either KitError (Maybe Text))+upstreamHash "" _ = pure (Right Nothing)+upstreamHash _ Nothing = pure (Right Nothing)+upstreamHash cacheDir (Just item) =+ case itemSources item of+ Left err -> pure (Left err)+ Right sources -> do+ result <- computeKitHash cacheDir (sources ^. #base) (sources ^. #files)+ pure $ case result of+ Left (KitSourceMissing _) -> Right Nothing+ Left err -> Left err+ Right h -> Right (Just h) scanInstalled :: KitConfig -> KitScope -> IO [(AgentAssetProvider, FilePath, Text, KitItemKind)] scanInstalled config scope = fmap concat $@@ -148,41 +273,61 @@ pure [(provider, Text.pack (dropAgentExtension provider f), AgentKind) | f <- files] else pure [] -renderStatusTable :: [StatusRow] -> IO ()-renderStatusTable [] = Text.IO.putStrLn "No kit items installed."-renderStatusTable rows = do- let displayRows = aggregateStatusRows rows- nameW = colWidth displayRows "NAME" (^. #name)- kindW = colWidth displayRows "TYPE" (^. #kind)- scopeW = colWidth displayRows "SCOPE" (^. #scope)- providersW = colWidth displayRows "PROVIDERS" (^. #providers)- instW = colWidth displayRows "INSTALLED" (renderMVer . view #installedVersion)- latW = colWidth displayRows "LATEST" (renderMVer . view #latestVersion)- hdr =- Text.justifyLeft (nameW + 2) ' ' "NAME"- <> Text.justifyLeft (kindW + 2) ' ' "TYPE"- <> Text.justifyLeft (scopeW + 2) ' ' "SCOPE"- <> Text.justifyLeft (providersW + 2) ' ' "PROVIDERS"- <> Text.justifyLeft (instW + 2) ' ' "INSTALLED"- <> Text.justifyLeft (latW + 2) ' ' "LATEST"- <> "STATE"- Text.IO.putStrLn hdr- mapM_ (printRow nameW kindW scopeW providersW instW latW) displayRows+-- | 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 ++ legacyNote) where- colWidth displayRows colTitle f =+ 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 =+ Text.justifyLeft (nameW + 2) ' ' "NAME"+ <> 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"++ 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 nameW kindW scopeW providersW instW latW row =- Text.IO.putStrLn $- Text.justifyLeft (nameW + 2) ' ' (row ^. #name)- <> Text.justifyLeft (kindW + 2) ' ' (row ^. #kind)- <> Text.justifyLeft (scopeW + 2) ' ' (row ^. #scope)- <> Text.justifyLeft (providersW + 2) ' ' (row ^. #providers)- <> Text.justifyLeft (instW + 2) ' ' (renderMVer (row ^. #installedVersion))- <> Text.justifyLeft (latW + 2) ' ' (renderMVer (row ^. #latestVersion))- <> renderState (row ^. #state)+ printRow row =+ Text.justifyLeft (nameW + 2) ' ' (row ^. #name)+ <> 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) aggregateStatusRows :: [StatusRow] -> [StatusRow] aggregateStatusRows rows =@@ -195,18 +340,14 @@ row ^. #scope, row ^. #installedVersion, row ^. #latestVersion,- row ^. #state+ row ^. #conditions,+ row ^. #requestedVisibility,+ row ^. #effectiveVisibility ) sameKey a b = rowKey a == rowKey b summarize groupRows@(firstRow : _) = firstRow & #providers .~ Text.intercalate "," (sort (nub (map (^. #providers) groupRows))) summarize [] = error "aggregateStatusRows: empty group"--agentSourceBase :: FilePath -> AgentEntry -> FilePath-agentSourceBase repoDir entry =- case entry ^. #files of- Just _ -> repoDir </> Text.unpack (entry ^. #path)- Nothing -> repoDir </> takeDirectory (Text.unpack (entry ^. #path)) agentExtension :: AgentAssetProvider -> String agentExtension provider =
+ 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,494 +5,1858 @@ import Baikai.Interactive (InteractiveProvider (InteractiveClaude, InteractiveCodex)) import Baikai.Kit ( AgentEntry (..),- KitConfig (..),- KitItem (..),- KitItemKind (..),- KitManifest (..),- KitScope (UserScope),- KitState (..),- PullResult (..),- RemovalOutcome (..),- SidecarMeta (..),- SkillEntry (..),- classify,- collectStatus,- computeKitHash,- installItem,- pullKitRepo,- readSidecar,- renderUninstallReport,- safeItemName,- safeRelativePath,- safeUnder,- sidecarFileName,- sidecarPath,- stripYamlFrontmatter,- uninstallItem,- uninstallOutcomes,- updateKit,- )-import Baikai.Prelude-import Control.Exception (finally, try)-import Data.Aeson qualified as Aeson-import Data.ByteString qualified as BS-import Data.List (find, isSuffixOf)-import Data.Text qualified as Text-import Data.Text.Encoding qualified as Text.Encoding-import System.Directory- ( createDirectoryIfMissing,- doesDirectoryExist,- doesFileExist,- doesPathExist,- listDirectory,- removeDirectoryRecursive,- )-import System.Environment (lookupEnv, setEnv, unsetEnv)-import System.Exit (ExitCode (..))-import System.FilePath (takeDirectory, (</>))-import System.IO.Temp (withSystemTempDirectory)-import Test.Tasty (TestTree, defaultMain, localOption, testGroup)-import Test.Tasty.HUnit (assertBool, assertFailure, testCase, (@?=))-import Test.Tasty.Runners (NumThreads (NumThreads))--main :: IO ()-main =- defaultMain $- localOption (NumThreads 1) $- testGroup- "baikai-kit"- [ manifestTests,- hashTests,- pathSafetyTests,- frontmatterTests,- classifyTests,- statusFilesystemTests,- installRoundTripTests- ]--manifestTests :: TestTree-manifestTests =- testGroup- "Manifest backward compatibility"- [ fixtureCase "mori-kit.json" 2 4 0,- fixtureCase "rei-kit.json" 1 9 1,- fixtureCase "seihou-kit.json" 1 2 0- ]--fixtureCase :: FilePath -> Int -> Int -> Int -> TestTree-fixtureCase file expectedVersion expectedSkills expectedAgents =- testCase (file <> " decodes") $ do- manifest <- decodeFixture file- (manifest ^. #version) @?= expectedVersion- length (manifest ^. #skills) @?= expectedSkills- length (manifest ^. #agents) @?= expectedAgents--hashTests :: TestTree-hashTests =- testGroup- "Hash"- [ testCase "computeKitHash is deterministic regardless of input order" $- withSystemTempDirectory "baikai-kit-hash" $ \dir -> do- BS.writeFile (dir </> "a.md") "alpha"- BS.writeFile (dir </> "b.md") "beta"- BS.writeFile (dir </> "c.md") "gamma"- h1 <- computeKitHash dir ["a.md", "b.md", "c.md"]- h2 <- computeKitHash dir ["c.md", "a.md", "b.md"]- h1 @?= h2- assertBool "hash should carry sha256 prefix" ("sha256:" `Text.isPrefixOf` h1),- testCase "computeKitHash changes when file content changes" $- withSystemTempDirectory "baikai-kit-hash-mut" $ \dir -> do- BS.writeFile (dir </> "a.md") "alpha"- before <- computeKitHash dir ["a.md"]- BS.writeFile (dir </> "a.md") "alpha-modified"- after <- computeKitHash dir ["a.md"]- assertBool "hashes must differ after content change" (before /= after)- ]--classifyTests :: TestTree-classifyTests =- testGroup- "Status.classify"- [ testCase "no sidecar => unknown" $- classify Nothing (Just (mkSkillItem "foo" (Just "1.0"))) (Just "h") @?= KitUnknown,- testCase "no upstream entry with a sidecar => delisted" $- classify (Just (mkSidecar (Just "1.0") "h")) Nothing (Just "h") @?= KitDelisted,- testCase "version mismatch => outdated" $- classify- (Just (mkSidecar (Just "1.0") "h"))- (Just (mkSkillItem "foo" (Just "2.0")))- (Just "h")- @?= KitOutdated,- testCase "version and hash mismatch => dirty+outdated" $- classify- (Just (mkSidecar (Just "1.0") "h1"))- (Just (mkSkillItem "foo" (Just "2.0")))- (Just "h2")- @?= KitDirtyOutdated,- testCase "hash mismatch => dirty" $- classify- (Just (mkSidecar (Just "1.0") "h1"))- (Just (mkSkillItem "foo" (Just "1.0")))- (Just "h2")- @?= KitDirty,- testCase "version and hash match => up-to-date" $- classify- (Just (mkSidecar (Just "1.0") "h"))- (Just (mkSkillItem "foo" (Just "1.0")))- (Just "h")- @?= KitUpToDate,- testCase "no upstream hash on matching version => up-to-date" $- classify- (Just (mkSidecar (Just "1.0") "h"))- (Just (mkAgentItem "foo" (Just "1.0")))- Nothing- @?= KitUpToDate- ]--pathSafetyTests :: TestTree-pathSafetyTests =- testGroup- "Path safety"- [ testCase "safeRelativePath accepts and normalises harmless paths" $ do- safeRelativePath "SKILL.md" @?= Right "SKILL.md"- safeRelativePath "skills/review" @?= Right "skills/review"- safeRelativePath "a/./b" @?= Right ("a" </> "b"),- testCase "safeRelativePath rejects zip-slip, absolute paths, backslashes, and NUL" $ do- mapM_- (assertLeft . safeRelativePath)- ["", "/etc/passwd", "../x", "a/../../x", "..", "a/..", "a\\..\\b", "a\0b"],- testCase "safeItemName rejects multi-component and hidden names" $ do- safeItemName "reviewer" @?= Right "reviewer"- mapM_ (assertLeft . safeItemName) ["a/b", ".", ".hidden"],- testCase "safeUnder rejects an absolute right operand" $- assertLeft (safeUnder "/base" "/etc/passwd"),- testCase "install refuses a manifest file path that escapes the install root" $- withPreparedKitHome $ \home cache -> do- BS.writeFile (cache </> "kit.json") maliciousManifestJson- result <- try @ExitCode (installItem testConfig "evil" UserScope)- result @?= Left (ExitFailure 1)- assertFileMissing (takeDirectory home </> "escape.txt"),- testCase "uninstall refuses a traversal name" $- withPreparedKitHome $ \home _cache -> do- let victim = home </> "victim"- createDirectoryIfMissing True victim- result <- try @ExitCode (uninstallItem testConfig "../victim" UserScope)- result @?= Left (ExitFailure 1)- assertDirectoryExists victim- ]--frontmatterTests :: TestTree-frontmatterTests =- testGroup- "Frontmatter"- [ testCase "stripYamlFrontmatter handles LF, CRLF, and final delimiter without newline" $ do- stripYamlFrontmatter "---\nname: x\n---\nBody.\n" @?= "Body.\n"- stripYamlFrontmatter "---\r\nname: x\r\n---\r\nBody.\r\n" @?= "Body.\n"- stripYamlFrontmatter "---\nname: x\n---" @?= "",- testCase "stripYamlFrontmatter leaves non-frontmatter and unterminated blocks unchanged" $ do- stripYamlFrontmatter "Body.\n" @?= "Body.\n"- stripYamlFrontmatter "---\nname: x\nBody.\n" @?= "---\nname: x\nBody.\n"- ]--statusFilesystemTests :: TestTree-statusFilesystemTests =- testGroup- "Status filesystem"- [ testCase "sidecarPath drops any agent target extension" $- withPreparedKitHome $ \home _cache -> do- let claudeBase = home </> ".config" </> "testkit" </> "agents"- codexBase = home- sidecar = sidecarFileName testConfig- sidecarPath InteractiveClaude AgentKind "reviewer" claudeBase sidecar- @?= claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"- sidecarPath InteractiveCodex AgentKind "reviewer" codexBase sidecar- @?= codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json",- testCase "delisted installed item keeps sidecar version in status" $- withPreparedKitHome $ \_home cache -> do- installItem testConfig "demo" UserScope- BS.writeFile (cache </> "kit.json") manifestWithoutDemoJson- rows <- collectStatus testConfig cache [(UserScope, "user")]- let demoRows = filter ((== "demo") . view #name) rows- assertBool "expected demo status rows" (not (null demoRows))- mapM_ (\row -> row ^. #state @?= KitDelisted) demoRows- mapM_ (\row -> row ^. #installedVersion @?= Just "0.1.0") demoRows,- testCase "version and cached hash drift reports dirty+outdated" $- withPreparedKitHome $ \_home cache -> do- installItem testConfig "demo" UserScope- BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "changed instructions\n"- BS.writeFile (cache </> "kit.json") manifestWithDemoVersionJson- rows <- collectStatus testConfig cache [(UserScope, "user")]- let demoRows = filter ((== "demo") . view #name) rows- assertBool "expected demo status rows" (not (null demoRows))- mapM_ (\row -> row ^. #state @?= KitDirtyOutdated) demoRows- ]--installRoundTripTests :: TestTree-installRoundTripTests =- testGroup- "Install"- [ testCase "skill and agent round-trip through Claude and Codex layouts with sidecars" $- withPreparedKitHome $ \home _cache -> do- let config = testConfig- claudeBase = home </> ".config" </> "testkit" </> "agents"- codexBase = home- claudeSkill = claudeBase </> ".claude" </> "skills" </> "demo"- codexSkill = codexBase </> ".agents" </> "skills" </> "demo"- claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"- codexAgent = codexBase </> ".codex" </> "agents" </> "reviewer.toml"- claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"- codexAgentSidecar = codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json"- installItem config "demo" UserScope- assertFileExists (claudeSkill </> "SKILL.md")- assertFileExists (codexSkill </> "SKILL.md")- assertFileExists (claudeSkill </> ".testkit-kit.json")- meta <- readSidecar (claudeSkill </> ".testkit-kit.json")- case meta of- Just sidecar -> do- (sidecar ^. #name) @?= ("demo" :: Text)- (sidecar ^. #kind) @?= ("skill" :: Text)- Nothing -> assertFailure "expected a skill sidecar"- uninstallItem config "demo" UserScope- assertDirectoryMissing claudeSkill- assertDirectoryMissing codexSkill- installItem config "reviewer" UserScope- assertFileExists claudeAgent- assertFileExists codexAgent- assertFileExists claudeAgentSidecar- assertFileExists codexAgentSidecar- toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent- assertBool "Codex agent TOML should contain developer instructions" ("developer_instructions" `Text.isInfixOf` toml)- uninstallItem config "reviewer" UserScope- assertFileMissing claudeAgent- assertFileMissing codexAgent- assertFileMissing claudeAgentSidecar- assertFileMissing codexAgentSidecar,- testCase "Codex TOML strips CRLF YAML frontmatter" $- withPreparedKitHome $ \home cache -> do- let codexAgent = home </> ".codex" </> "agents" </> "reviewer.toml"- BS.writeFile (cache </> "agents" </> "reviewer.md") "---\r\nname: reviewer\r\n---\r\nReview carefully.\r\n"- installItem testConfig "reviewer" UserScope- toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent- assertBool "frontmatter name should be stripped" (not ("name: reviewer" `Text.isInfixOf` toml))- assertBool "CR characters should be stripped" (not ("\r" `Text.isInfixOf` toml)),- testCase "failed provider write rolls back all staged writes" $- withPreparedKitHome $ \home _cache -> do- let claudeBase = home </> ".config" </> "testkit" </> "agents"- claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"- claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"- BS.writeFile (home </> ".codex") ""- result <- try @ExitCode (installItem testConfig "reviewer" UserScope)- result @?= Left (ExitFailure 1)- assertFileMissing claudeAgent- assertFileMissing claudeAgentSidecar- tmpFiles <- findFilesWithSuffix home ".baikai-kit-tmp"- tmpFiles @?= [],- testCase "renderUninstallReport names actual assets and stale metadata" $ do- renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False]- @?= "Uninstalled skill 'demo' from user scope (claude)."- renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False, RemovalOutcome InteractiveCodex True False False]- @?= "Uninstalled skill 'demo' from user scope (claude,codex)."- renderUninstallReport "reviewer" UserScope [RemovalOutcome InteractiveClaude False False True]- @?= "Removed stale kit metadata for 'reviewer' from user scope."- renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude False False False]- @?= "'demo' is not installed in user scope.",- testCase "uninstallOutcomes reports per-provider removals" $- withPreparedKitHome $ \home _cache -> do- let codexSkill = home </> ".agents" </> "skills" </> "demo"- installItem testConfig "demo" UserScope- removeDirectoryRecursive codexSkill- outcomes <- uninstallOutcomes testConfig "demo" UserScope- let claudeOutcome = findOutcome InteractiveClaude outcomes- codexOutcome = findOutcome InteractiveCodex outcomes- view #skillRemoved claudeOutcome @?= True- view #skillRemoved codexOutcome @?= False- view #agentRemoved codexOutcome @?= False- view #sidecarRemoved codexOutcome @?= False,- testCase "pull failure is returned and update exits nonzero" $- withPreparedKitHome $ \_home cache -> do- result <- pullKitRepo testConfig cache- case result of- PullFailed _ -> pure ()- PullSucceeded -> assertFailure "expected fake .git cache pull to fail"- updateResult <- try @ExitCode (updateKit testConfig Nothing)- updateResult @?= Left (ExitFailure 1)- ]--decodeFixture :: FilePath -> IO KitManifest-decodeFixture file = do- bytes <- BS.readFile ("test/fixtures" </> file)- case Aeson.eitherDecodeStrict' bytes of- Right manifest -> pure manifest- Left err -> assertFailure ("failed to decode " <> file <> ": " <> err)--mkSidecar :: Maybe Text -> Text -> SidecarMeta-mkSidecar mVersion h =- SidecarMeta- { name = "foo",- kind = "skill",- version = mVersion,- hash = h,- installedAt = "2026-05-13T00:00:00Z"- }--mkSkillItem :: Text -> Maybe Text -> KitItem-mkSkillItem n mVersion =- KitSkillItem- SkillEntry- { name = n,- description = "x",- version = mVersion,- path = "skills/foo",- files = ["SKILL.md"]- }--mkAgentItem :: Text -> Maybe Text -> KitItem-mkAgentItem n mVersion =- KitAgentItem- AgentEntry- { name = n,- description = "x",- version = mVersion,- path = "agents/foo.md",- files = Nothing- }--testConfig :: KitConfig-testConfig =- KitConfig- { toolName = "testkit",- repoUrl = "file:///not-used",- providers = [InteractiveClaude, InteractiveCodex]- }--withPreparedKitHome :: (FilePath -> FilePath -> IO a) -> IO a-withPreparedKitHome action =- withSystemTempDirectory "baikai-kit-home" $ \tmp -> do- oldHome <- lookupEnv "HOME"- let home = tmp </> "home"- cache = home </> ".cache" </> "testkit" </> "kit"- createDirectoryIfMissing True (cache </> ".git")- createDirectoryIfMissing True (cache </> "skills" </> "demo")- createDirectoryIfMissing True (cache </> "agents")- BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "skill instructions\n"- BS.writeFile (cache </> "agents" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"- BS.writeFile (cache </> "kit.json") manifestJson- setEnv "HOME" home- action home cache `finally` restoreHome oldHome- where- restoreHome Nothing = unsetEnv "HOME"- restoreHome (Just value) = setEnv "HOME" value--manifestJson :: BS.ByteString-manifestJson =- Text.Encoding.encodeUtf8 $- Text.concat- [ "{\"version\":2,",- "\"skills\":[{",- "\"name\":\"demo\",",- "\"description\":\"Demo skill\",",- "\"version\":\"0.1.0\",",- "\"path\":\"skills/demo\",",- "\"files\":[\"SKILL.md\"]",- "}],",- "\"agents\":[{",- "\"name\":\"reviewer\",",- "\"description\":\"Review agent\",",- "\"version\":\"0.1.0\",",- "\"path\":\"agents/reviewer.md\"",- "}]} "- ]--manifestWithoutDemoJson :: BS.ByteString-manifestWithoutDemoJson =- Text.Encoding.encodeUtf8 $- Text.concat- [ "{\"version\":2,",- "\"skills\":[],",- "\"agents\":[{",- "\"name\":\"reviewer\",",- "\"description\":\"Review agent\",",- "\"version\":\"0.1.0\",",- "\"path\":\"agents/reviewer.md\"",- "}]} "- ]--manifestWithDemoVersionJson :: BS.ByteString-manifestWithDemoVersionJson =- Text.Encoding.encodeUtf8 $- Text.concat- [ "{\"version\":2,",- "\"skills\":[{",- "\"name\":\"demo\",",- "\"description\":\"Demo skill\",",- "\"version\":\"0.2.0\",",- "\"path\":\"skills/demo\",",- "\"files\":[\"SKILL.md\"]",- "}],",- "\"agents\":[{",- "\"name\":\"reviewer\",",- "\"description\":\"Review agent\",",- "\"version\":\"0.1.0\",",- "\"path\":\"agents/reviewer.md\"",- "}]} "- ]--maliciousManifestJson :: BS.ByteString-maliciousManifestJson =- Text.Encoding.encodeUtf8 $- Text.concat- [ "{\"version\":2,",- "\"skills\":[{",- "\"name\":\"evil\",",- "\"description\":\"Evil skill\",",- "\"version\":\"0.1.0\",",- "\"path\":\"skills/demo\",",- "\"files\":[\"../../../../escape.txt\"]",- "}],",- "\"agents\":[]} "- ]--assertLeft :: (Show b) => Either a b -> IO ()-assertLeft result =- case result of- Left _ -> pure ()- Right value -> assertFailure ("expected Left, got Right " <> show value)--assertFileExists :: FilePath -> IO ()-assertFileExists path = do- exists <- doesFileExist path- assertBool ("expected file to exist: " <> path) exists--assertFileMissing :: FilePath -> IO ()-assertFileMissing path = do- exists <- doesFileExist path- assertBool ("expected file to be missing: " <> path) (not exists)--assertDirectoryMissing :: FilePath -> IO ()-assertDirectoryMissing path = do- exists <- doesDirectoryExist path- assertBool ("expected directory to be missing: " <> path) (not exists)--assertDirectoryExists :: FilePath -> IO ()-assertDirectoryExists path = do- exists <- doesDirectoryExist path- assertBool ("expected directory to exist: " <> path) exists--findFilesWithSuffix :: FilePath -> String -> IO [FilePath]-findFilesWithSuffix root suffix = do- exists <- doesPathExist root- if not exists- then pure []- else do- isDir <- doesDirectoryExist root- if not isDir- then pure [root | suffix `isSuffixOf` root]- else do- names <- listDirectory root- fmap concat $ mapM (\name -> findFilesWithSuffix (root </> name) suffix) names--findOutcome :: InteractiveProvider -> [RemovalOutcome] -> RemovalOutcome-findOutcome expected outcomes =- case find ((== expected) . view #provider) outcomes of- Just outcome -> outcome- Nothing -> error "expected provider outcome"+ InstallOptions (..),+ KitCommand (..),+ KitCondition (..),+ KitConfig (..),+ KitError (..),+ KitItem (..),+ KitItemKind (..),+ KitManifest (..),+ KitScope (..),+ KitVisibility (..),+ OutputFormat (..),+ OverwritePolicy (..),+ PlannedWrite (..),+ PullResult (..),+ RemovalOutcome (..),+ RepoRefresh (..),+ SidecarMeta (..),+ SkillEntry (..),+ UpstreamAvailability (..),+ WriteContent (..),+ addDisabledSkill,+ agentDirsForSession,+ checkVisibility,+ classify,+ codexSessionArgs,+ collectStatus,+ computeKitHash,+ conditionLabel,+ defaultInstallOptions,+ enableSkillsArgs,+ executePlanWith,+ findProjectRoot,+ installItem,+ installedCopies,+ kitCommandParser,+ kitConfig,+ kitJsonFormatVersion,+ kitStatus,+ listDocument,+ loadManifest,+ projectRootByMarkers,+ pullKitRepo,+ readSidecar,+ readSkillEntries,+ reinstallPresent,+ relativeLinkTarget,+ removeDisabledSkill,+ renderConditions,+ renderStatusTable,+ renderUninstallReport,+ runKit,+ runKitCommand,+ safeItemName,+ safeRelativePath,+ safeSourcePath,+ sidecarFileName,+ sidecarPath,+ statusDocument,+ stripYamlFrontmatter,+ uninstallItem,+ updateDocument,+ updateKit,+ )+import Baikai.Prelude+import Control.Concurrent (threadDelay)+import Control.Exception (finally, try)+import Control.Monad (forM_, void)+import Data.Aeson (Value (..))+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Bits ((.&.))+import Data.ByteString qualified as BS+import Data.ByteString.Lazy qualified as LBS+import Data.Foldable (toList)+import Data.IORef (newIORef, readIORef, writeIORef)+import Data.List (find, isInfixOf, isSuffixOf, nub, sort)+import Data.Text qualified as Text+import Data.Text.Encoding qualified as Text.Encoding+import GHC.IO.Handle (hDuplicate, hDuplicateTo)+import Options.Applicative+ ( ParserResult (..),+ defaultPrefs,+ execParserPure,+ getParseResult,+ helper,+ info,+ renderFailure,+ (<**>),+ )+import System.Directory+ ( canonicalizePath,+ createDirectoryIfMissing,+ createDirectoryLink,+ createFileLink,+ doesDirectoryExist,+ doesFileExist,+ doesPathExist,+ getCurrentDirectory,+ getSymbolicLinkTarget,+ listDirectory,+ pathIsSymbolicLink,+ removeDirectoryRecursive,+ removeFile,+ renameFile,+ withCurrentDirectory,+ )+import System.Environment (lookupEnv, setEnv, unsetEnv)+import System.Exit (ExitCode (..))+import System.FilePath (takeDirectory, (</>))+import System.IO (hClose, hFlush, stdout)+import System.IO.Temp (withSystemTempDirectory, withSystemTempFile)+import System.Posix.Files qualified as Posix+import System.Process (readProcessWithExitCode)+import Test.Tasty (TestTree, defaultMain, localOption, testGroup)+import Test.Tasty.HUnit (Assertion, assertBool, assertFailure, testCase, (@?=))+import Test.Tasty.Runners (NumThreads (NumThreads))+import Toml qualified++main :: IO ()+main =+ defaultMain $+ localOption (NumThreads 1) $+ testGroup+ "baikai-kit"+ [ manifestTests,+ hashTests,+ pathSafetyTests,+ symlinkSafetyTests,+ frontmatterTests,+ classifyTests,+ statusFilesystemTests,+ installRoundTripTests,+ typedErrorTests,+ installFidelityTests,+ projectRootTests,+ commandTests,+ jsonTests,+ visibilityTests,+ codexVisibilityTests,+ visibilityStatusTests,+ visibilityRecoveryTests+ ]++manifestTests :: TestTree+manifestTests =+ testGroup+ "Manifest backward compatibility"+ [ fixtureCase "mori-kit.json" 2 4 0,+ fixtureCase "rei-kit.json" 1 9 1,+ fixtureCase "seihou-kit.json" 1 2 0+ ]++fixtureCase :: FilePath -> Int -> Int -> Int -> TestTree+fixtureCase file expectedVersion expectedSkills expectedAgents =+ testCase (file <> " decodes") $ do+ manifest <- decodeFixture file+ (manifest ^. #version) @?= expectedVersion+ length (manifest ^. #skills) @?= expectedSkills+ length (manifest ^. #agents) @?= expectedAgents++hashTests :: TestTree+hashTests =+ testGroup+ "Hash"+ [ testCase "computeKitHash is deterministic regardless of input order" $+ withSystemTempDirectory "baikai-kit-hash" $ \dir -> do+ BS.writeFile (dir </> "a.md") "alpha"+ BS.writeFile (dir </> "b.md") "beta"+ BS.writeFile (dir </> "c.md") "gamma"+ h1 <- assertRight =<< computeKitHash dir "." ["a.md", "b.md", "c.md"]+ h2 <- assertRight =<< computeKitHash dir "." ["c.md", "a.md", "b.md"]+ h1 @?= h2+ assertBool "hash should carry sha256 prefix" ("sha256:" `Text.isPrefixOf` h1),+ testCase "computeKitHash changes when file content changes" $+ withSystemTempDirectory "baikai-kit-hash-mut" $ \dir -> do+ BS.writeFile (dir </> "a.md") "alpha"+ before <- assertRight =<< computeKitHash dir "." ["a.md"]+ BS.writeFile (dir </> "a.md") "alpha-modified"+ after <- assertRight =<< computeKitHash dir "." ["a.md"]+ assertBool "hashes must differ after content change" (before /= after)+ ]++classifyTests :: TestTree+classifyTests =+ testGroup+ "Status.classify"+ [ testCase "no sidecar => unknown" $+ classify Nothing (Just (mkSkillItem "foo" (Just "1.0"))) (Just "h") @?= [KitUnknown],+ testCase "no upstream entry with a sidecar => delisted" $+ classify (Just (mkSidecar (Just "1.0") "h")) Nothing (Just "h") @?= [KitDelisted],+ testCase "version mismatch => outdated" $+ classify+ (Just (mkSidecar (Just "1.0") "h"))+ (Just (mkSkillItem "foo" (Just "2.0")))+ (Just "h")+ @?= [KitOutdated],+ testCase "version and hash mismatch => outdated+changed-upstream" $+ classify+ (Just (mkSidecar (Just "1.0") "h1"))+ (Just (mkSkillItem "foo" (Just "2.0")))+ (Just "h2")+ @?= [KitOutdated, KitChangedUpstream],+ testCase "hash mismatch => changed-upstream" $+ classify+ (Just (mkSidecar (Just "1.0") "h1"))+ (Just (mkSkillItem "foo" (Just "1.0")))+ (Just "h2")+ @?= [KitChangedUpstream],+ testCase "version and hash match => up-to-date" $+ classify+ (Just (mkSidecar (Just "1.0") "h"))+ (Just (mkSkillItem "foo" (Just "1.0")))+ (Just "h")+ @?= [],+ testCase "no upstream hash on matching version => up-to-date" $+ classify+ (Just (mkSidecar (Just "1.0") "h"))+ (Just (mkAgentItem "foo" (Just "1.0")))+ Nothing+ @?= [],+ testCase "renderConditions joins labels in order" $ do+ renderConditions [] @?= "up-to-date"+ renderConditions [KitOutdated, KitChangedUpstream, KitLocallyModified] @?= "outdated+changed-upstream+modified"+ ]++pathSafetyTests :: TestTree+pathSafetyTests =+ testGroup+ "Path safety"+ [ testCase "safeRelativePath accepts and normalises harmless paths" $ do+ safeRelativePath "SKILL.md" @?= Right "SKILL.md"+ safeRelativePath "skills/review" @?= Right "skills/review"+ safeRelativePath "a/./b" @?= Right ("a" </> "b"),+ testCase "safeRelativePath rejects zip-slip, absolute paths, backslashes, and NUL" $ do+ mapM_+ (assertLeft . safeRelativePath)+ ["", "/etc/passwd", "../x", "a/../../x", "..", "a/..", "a\\..\\b", "a\0b"],+ testCase "safeItemName rejects multi-component and hidden names" $ do+ safeItemName "reviewer" @?= Right "reviewer"+ mapM_ (assertLeft . safeItemName) ["a/b", ".", ".hidden"],+ testCase "install refuses a manifest file path that escapes the install root" $+ withPreparedKitHome $ \home cache -> do+ BS.writeFile (cache </> "kit.json") maliciousManifestJson+ result <- installItem testConfig "evil" UserScope defaultInstallOptions+ assertKitError "KitUnsafePath" isUnsafePath result+ assertFileMissing (takeDirectory home </> "escape.txt")+ exitResult <- try @ExitCode (runKit testConfig (KitInstall (Just "evil") UserScope defaultInstallOptions))+ exitResult @?= Left (ExitFailure 1),+ testCase "uninstall refuses a traversal name" $+ withPreparedKitHome $ \home _cache -> do+ let victim = home </> "victim"+ createDirectoryIfMissing True victim+ result <- uninstallItem testConfig "../victim" UserScope+ assertKitError "KitUnsafeName" isUnsafeName result+ assertDirectoryExists victim+ exitResult <- try @ExitCode (runKit testConfig (KitUninstall "../victim" UserScope))+ exitResult @?= Left (ExitFailure 1)+ assertDirectoryExists victim+ ]++symlinkSafetyTests :: TestTree+symlinkSafetyTests =+ testGroup+ "Symlink safety"+ [ testCase "safeSourcePath refuses a symlinked component" $+ withPreparedKitHome $ \home cache -> do+ plantSymlinkedSource home cache+ refused <- safeSourcePath cache ("skills" </> "demo" </> "sub" </> "secret.txt")+ refused @?= Left (KitSourceSymlink (cache </> "skills" </> "demo" </> "sub"))+ absolute <- safeSourcePath cache "/etc/passwd"+ case absolute of+ Left (KitUnsafePath _ _) -> pure ()+ other -> assertFailure ("expected KitUnsafePath, got " <> show other)+ plain <- safeSourcePath cache ("skills" </> "demo" </> "SKILL.md")+ plain @?= Right (cache </> "skills" </> "demo" </> "SKILL.md"),+ testCase "computeKitHash refuses a symlinked source" $+ withPreparedKitHome $ \home cache -> do+ plantSymlinkedSource home cache+ hashed <- computeKitHash cache ("skills" </> "demo") ["SKILL.md", "sub" </> "secret.txt"]+ hashed @?= Left (KitSourceSymlink (cache </> "skills" </> "demo" </> "sub")),+ testCase "install refuses a symlinked source and writes nothing" $+ withPreparedKitHome $ \home cache -> do+ plantSymlinkedSource home cache+ let claudeSkill = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"+ result <- installItem testConfig "demo" UserScope defaultInstallOptions+ assertKitError "KitSourceSymlink" isSourceSymlink result+ assertFileMissing (claudeSkill </> "sub" </> "secret.txt")+ assertFileMissing (claudeSkill </> "SKILL.md"),+ testCase "status reports refused when upstream lists a symlinked source" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ plantSymlinkedSource home cache+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ let demoRows = filter ((== "demo") . view #name) rows+ assertBool "expected demo status rows" (not (null demoRows))+ mapM_ (\row -> row ^. #conditions @?= [KitUpstreamRefused]) demoRows,+ testCase "conditionLabel names the refused condition" $+ conditionLabel KitUpstreamRefused @?= "refused"+ ]++frontmatterTests :: TestTree+frontmatterTests =+ testGroup+ "Frontmatter"+ [ testCase "stripYamlFrontmatter handles LF, CRLF, and final delimiter without newline" $ do+ stripYamlFrontmatter "---\nname: x\n---\nBody.\n" @?= "Body.\n"+ stripYamlFrontmatter "---\r\nname: x\r\n---\r\nBody.\r\n" @?= "Body.\n"+ stripYamlFrontmatter "---\nname: x\n---" @?= "",+ testCase "stripYamlFrontmatter leaves non-frontmatter and unterminated blocks unchanged" $ do+ stripYamlFrontmatter "Body.\n" @?= "Body.\n"+ stripYamlFrontmatter "---\nname: x\nBody.\n" @?= "---\nname: x\nBody.\n",+ testCase "stripYamlFrontmatter normalises line endings on every branch" $ do+ stripYamlFrontmatter "Body.\r\n" @?= "Body.\n"+ stripYamlFrontmatter "---\r\nname: x\r\nBody.\r\n" @?= "---\nname: x\nBody.\n"+ ]++statusFilesystemTests :: TestTree+statusFilesystemTests =+ testGroup+ "Status filesystem"+ [ testCase "sidecarPath drops any agent target extension" $+ withPreparedKitHome $ \home _cache -> do+ let claudeBase = home </> ".config" </> "testkit" </> "agents"+ codexBase = home+ sidecar = sidecarFileName testConfig+ sidecarPath InteractiveClaude AgentKind "reviewer" claudeBase sidecar+ @?= claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"+ sidecarPath InteractiveCodex AgentKind "reviewer" codexBase sidecar+ @?= codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json",+ testCase "delisted installed item keeps sidecar version in status" $+ withPreparedKitHome $ \_home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ BS.writeFile (cache </> "kit.json") manifestWithoutDemoJson+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ let demoRows = filter ((== "demo") . view #name) rows+ assertBool "expected demo status rows" (not (null demoRows))+ mapM_ (\row -> row ^. #conditions @?= [KitDelisted]) demoRows+ mapM_ (\row -> row ^. #installedVersion @?= Just "0.1.0") demoRows,+ testCase "version and cached hash drift reports outdated+changed-upstream" $+ withPreparedKitHome $ \_home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "changed instructions\n"+ BS.writeFile (cache </> "kit.json") manifestWithDemoVersionJson+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ let demoRows = filter ((== "demo") . view #name) rows+ assertBool "expected demo status rows" (not (null demoRows))+ mapM_ (\row -> row ^. #conditions @?= [KitOutdated, KitChangedUpstream]) demoRows,+ testCase "an installed item reports no conditions before an edit" $+ withPreparedKitHome $ \_home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ let demoRows = filter ((== "demo") . view #name) rows+ assertBool "expected demo status rows" (not (null demoRows))+ mapM_ (\row -> row ^. #conditions @?= []) demoRows,+ testCase "editing an installed file reports modified" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ BS.writeFile (userClaudeSkill home </> "SKILL.md") "my edits"+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ demoConditions rows "claude" >>= (@?= [KitLocallyModified])+ demoConditions rows "codex" >>= (@?= []),+ testCase "a legacy sidecar reports edits-unknown" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ BS.writeFile (userClaudeSkill home </> ".testkit-kit.json") legacySidecarJson+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ conditions <- demoConditions rows "claude"+ assertBool ("expected edits-unknown in " <> show conditions) (KitLocalEditsUnknown `elem` conditions)+ assertBool ("expected no modified in " <> show conditions) (KitLocallyModified `notElem` conditions),+ testCase "modified composes with outdated and changed-upstream" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ BS.writeFile (userClaudeSkill home </> "SKILL.md") "my edits"+ BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "changed instructions\n"+ BS.writeFile (cache </> "kit.json") manifestWithDemoVersionJson+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ demoConditions rows "claude" >>= (@?= [KitOutdated, KitChangedUpstream, KitLocallyModified]),+ testCase "status reports modified for exactly what update would skip" $+ withPreparedKitHome $ \home cache -> do+ -- Keep project scope inside the temporary HOME so the update's+ -- project-scope scan never looks at the working directory.+ let config = testConfig & #projectRoot .~ pure (home </> "project")+ _ <- assertRight =<< installItem config "demo" UserScope defaultInstallOptions+ _ <- assertRight =<< installItem config "reviewer" UserScope (InstallOptions Nothing True)+ BS.writeFile (userClaudeSkill home </> "SKILL.md") "my edits"+ rows <- collectStatus config cache [(UserScope, "user"), (ProjectScope, "project")]+ let toScope scopeText = if scopeText == "project" then ProjectScope else UserScope+ modifiedByStatus =+ nub+ [ (row ^. #name, toScope (row ^. #scope))+ | row <- rows,+ KitLocallyModified `elem` row ^. #conditions+ ]+ manifest <- assertRight =<< loadManifest cache+ report <- assertRight =<< reinstallPresent config cache manifest Nothing KeepLocalEdits+ sort (report ^. #skipped) @?= sort modifiedByStatus+ modifiedByStatus @?= [("demo", UserScope)]+ ]+ where+ userClaudeSkill home = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"+ demoConditions rows providerText =+ case filter (\row -> row ^. #name == "demo" && row ^. #providers == providerText) rows of+ [row] -> pure (row ^. #conditions)+ other -> assertFailure ("expected one " <> Text.unpack providerText <> " demo row, got " <> show other)++installRoundTripTests :: TestTree+installRoundTripTests =+ testGroup+ "Install"+ [ testCase "skill and agent round-trip through Claude and Codex layouts with sidecars" $+ withPreparedKitHome $ \home _cache -> do+ let config = testConfig+ claudeBase = home </> ".config" </> "testkit" </> "agents"+ codexBase = home+ claudeSkill = claudeBase </> ".claude" </> "skills" </> "demo"+ codexSkill = codexBase </> ".agents" </> "skills" </> "demo"+ claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"+ codexAgent = codexBase </> ".codex" </> "agents" </> "reviewer.toml"+ claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"+ codexAgentSidecar = codexBase </> ".codex" </> "agents" </> "reviewer.testkit-kit.json"+ _ <- assertRight =<< installItem config "demo" UserScope defaultInstallOptions+ assertFileExists (claudeSkill </> "SKILL.md")+ assertFileExists (codexSkill </> "SKILL.md")+ assertFileExists (claudeSkill </> ".testkit-kit.json")+ meta <- readSidecar (claudeSkill </> ".testkit-kit.json")+ case meta of+ Just sidecar -> do+ (sidecar ^. #name) @?= ("demo" :: Text)+ (sidecar ^. #kind) @?= ("skill" :: Text)+ Nothing -> assertFailure "expected a skill sidecar"+ _ <- assertRight =<< uninstallItem config "demo" UserScope+ assertDirectoryMissing claudeSkill+ assertDirectoryMissing codexSkill+ _ <- assertRight =<< installItem config "reviewer" UserScope (InstallOptions Nothing True)+ assertFileExists claudeAgent+ assertFileExists codexAgent+ assertFileExists claudeAgentSidecar+ assertFileExists codexAgentSidecar+ toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent+ assertBool "Codex agent TOML should contain developer instructions" ("developer_instructions" `Text.isInfixOf` toml)+ _ <- assertRight =<< uninstallItem config "reviewer" UserScope+ assertFileMissing claudeAgent+ assertFileMissing codexAgent+ assertFileMissing claudeAgentSidecar+ assertFileMissing codexAgentSidecar,+ testCase "Codex TOML strips CRLF YAML frontmatter" $+ withPreparedKitHome $ \home cache -> do+ let codexAgent = home </> ".codex" </> "agents" </> "reviewer.toml"+ BS.writeFile (cache </> "agents" </> "reviewer.md") "---\r\nname: reviewer\r\n---\r\nReview carefully.\r\n"+ _ <- assertRight =<< installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+ toml <- Text.Encoding.decodeUtf8 <$> BS.readFile codexAgent+ assertBool "frontmatter name should be stripped" (not ("name: reviewer" `Text.isInfixOf` toml))+ assertBool "CR characters should be stripped" (not ("\r" `Text.isInfixOf` toml)),+ testCase "failed provider write rolls back all staged writes" $+ withPreparedKitHome $ \home _cache -> do+ let claudeBase = home </> ".config" </> "testkit" </> "agents"+ claudeAgent = claudeBase </> ".claude" </> "agents" </> "reviewer.md"+ claudeAgentSidecar = claudeBase </> ".claude" </> "agents" </> "reviewer.testkit-kit.json"+ BS.writeFile (home </> ".codex") ""+ result <- installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+ assertKitError "KitWriteFailed" isWriteFailed result+ assertFileMissing claudeAgent+ assertFileMissing claudeAgentSidecar+ tmpFiles <- findFilesWithSuffix home ".baikai-kit-tmp"+ tmpFiles @?= [],+ testCase "renderUninstallReport names actual assets and stale metadata" $ do+ renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False [] []]+ @?= "Uninstalled skill 'demo' from user scope (claude)."+ renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude True False False [] [], RemovalOutcome InteractiveCodex True False False [] []]+ @?= "Uninstalled skill 'demo' from user scope (claude,codex)."+ renderUninstallReport "reviewer" UserScope [RemovalOutcome InteractiveClaude False False True [] []]+ @?= "Removed stale kit metadata for 'reviewer' from user scope."+ renderUninstallReport "demo" UserScope [RemovalOutcome InteractiveClaude False False False [] []]+ @?= "'demo' is not installed in user scope.",+ testCase "uninstallItem reports per-provider removals" $+ withPreparedKitHome $ \home _cache -> do+ let codexSkill = home </> ".agents" </> "skills" </> "demo"+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ removeDirectoryRecursive codexSkill+ outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope+ let claudeOutcome = findOutcome InteractiveClaude outcomes+ codexOutcome = findOutcome InteractiveCodex outcomes+ view #skillRemoved claudeOutcome @?= True+ view #skillRemoved codexOutcome @?= False+ view #agentRemoved codexOutcome @?= False+ view #sidecarRemoved codexOutcome @?= False,+ testCase "pull failure is returned as a typed error" $+ withPreparedKitHome $ \_home cache -> do+ result <- pullKitRepo testConfig cache+ case result of+ PullFailed _ -> pure ()+ PullSucceeded -> assertFailure "expected fake .git cache pull to fail"+ updateResult <- updateKit testConfig Nothing KeepLocalEdits+ assertKitError "KitPullFailed" isPullFailed updateResult+ ]++typedErrorTests :: TestTree+typedErrorTests =+ testGroup+ "Typed errors"+ [ testCase "loadManifest returns typed errors" $+ withSystemTempDirectory "baikai-kit-manifest" $ \dir -> do+ missing <- loadManifest dir+ assertKitError "KitManifestMissing" isManifestMissing missing+ BS.writeFile (dir </> "kit.json") "{"+ invalid <- loadManifest dir+ assertKitError "KitManifestInvalid" isManifestInvalid invalid,+ testCase "installItem returns KitItemNotFound" $+ withPreparedKitHome $ \_home _cache -> do+ result <- installItem testConfig "nope" UserScope defaultInstallOptions+ assertKitError "KitItemNotFound" (== KitItemNotFound "nope") result,+ testCase "kit status offline on a fresh HOME exits 0" $+ withSystemTempDirectory "baikai-kit-offline" $ \tmp -> do+ oldHome <- lookupEnv "HOME"+ oldCodexHome <- lookupEnv "CODEX_HOME"+ let home = tmp </> "home"+ config = testConfig & #repoUrl .~ "file:///nonexistent-kit"+ createDirectoryIfMissing True home+ setEnv "HOME" home+ setEnv "CODEX_HOME" (home </> ".codex")+ flip finally (restoreHome oldHome >> restoreCodexHome oldCodexHome) $ do+ exitResult <- try @ExitCode (runKit config (KitStatus HumanOutput))+ exitResult @?= Right ()+ report <- kitStatus config+ (report ^. #rows) @?= []+ case report ^. #upstream of+ UpstreamUnavailable (KitCloneFailed _ _) -> pure ()+ other -> assertFailure ("expected an unavailable upstream, got " <> show other)+ ]++installFidelityTests :: TestTree+installFidelityTests =+ testGroup+ "Install fidelity"+ [ testCase "multi-file agent installs every listed file" $+ withPreparedKitHome $ \home cache -> do+ plantMultiFileAgent cache+ let claudeBase = home </> ".config" </> "testkit" </> "agents"+ claudeAgents = claudeBase </> ".claude" </> "agents"+ codexAgents = home </> ".codex" </> "agents"+ _ <- assertRight =<< installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+ assertFileExists (claudeAgents </> "reviewer.md")+ assertFileExists (claudeAgents </> "reviewer" </> "guide.md")+ assertFileExists (codexAgents </> "reviewer.toml")+ assertFileExists (codexAgents </> "reviewer" </> "guide.md")+ meta <- readSidecar (claudeAgents </> "reviewer.testkit-kit.json")+ case meta of+ Just sidecar -> (sidecar ^. #installedFiles) @?= Just ["reviewer.md", "reviewer" <> "/" <> "guide.md"]+ Nothing -> assertFailure "expected an agent sidecar"+ _ <- assertRight =<< uninstallItem testConfig "reviewer" UserScope+ assertDirectoryMissing (claudeAgents </> "reviewer")+ assertDirectoryMissing (codexAgents </> "reviewer"),+ testCase "phase-two failure restores the previous files" $+ withSystemTempDirectory "baikai-kit-journal" $ \dir -> do+ BS.writeFile (dir </> "a.txt") "old"+ let writes =+ [ PlannedWrite {destination = dir </> "a.txt", content = WriteBytes "new"},+ PlannedWrite {destination = dir </> "b.txt", content = WriteBytes "new"}+ ]+ failingRename temp dest+ | "b.txt" `isSuffixOf` dest = do+ takeDirectory temp @?= dir+ assertBool "temporary should keep the tmp suffix" (".baikai-kit-tmp" `isSuffixOf` temp)+ assertBool "temporary name should be unique" (temp /= dest <> ".baikai-kit-tmp")+ ioError (userError "boom")+ | otherwise = renameFile temp dest+ result <- executePlanWith failingRename writes+ case result of+ Left (KitWriteFailed _ restored broken) -> do+ restored @?= [dir </> "a.txt"]+ broken @?= []+ other -> assertFailure ("expected KitWriteFailed, got " <> show other)+ kept <- BS.readFile (dir </> "a.txt")+ kept @?= "old"+ assertFileMissing (dir </> "b.txt")+ tmpFiles <- findFilesWithSuffix dir ".baikai-kit-tmp"+ tmpFiles @?= []+ bakFiles <- findFilesWithSuffix dir ".baikai-kit-bak"+ bakFiles @?= [],+ testCase "destination directory is refused before any write" $+ withPreparedKitHome $ \home _cache -> do+ let claudeAgents = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "agents"+ createDirectoryIfMissing True (claudeAgents </> "reviewer.md")+ result <- installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+ assertKitError "KitWriteFailed" isWriteFailed result+ assertFileMissing (home </> ".codex" </> "agents" </> "reviewer.toml"),+ testCase "unsupported manifest version is refused" $+ withPreparedKitHome $ \_home cache -> do+ BS.writeFile (cache </> "kit.json") manifestWithUnsupportedVersionJson+ loaded <- loadManifest cache+ case loaded of+ Left (KitManifestVersionUnsupported _ 99) -> pure ()+ other -> assertFailure ("expected KitManifestVersionUnsupported 99, got " <> show other)+ installed <- installItem testConfig "demo" UserScope defaultInstallOptions+ assertKitError "KitManifestVersionUnsupported" isVersionUnsupported installed,+ testCase "a sidecar written before the installed-file fields still decodes" $+ case Aeson.eitherDecodeStrict' legacySidecarJson :: Either String SidecarMeta of+ Right meta -> do+ (meta ^. #name) @?= ("demo" :: Text)+ (meta ^. #installedFiles) @?= Nothing+ (meta ^. #installedHash) @?= Nothing+ Left err -> assertFailure ("expected a legacy sidecar to decode: " <> err),+ testCase "update skips locally modified items unless forced" $+ withPreparedKitHome $ \home cache -> do+ let claudeSkill = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"+ upstream = cache </> "skills" </> "demo" </> "SKILL.md"+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ BS.writeFile (claudeSkill </> "SKILL.md") "my edits"+ BS.writeFile upstream "new upstream"+ manifest <- assertRight =<< loadManifest cache+ kept <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits+ (kept ^. #skipped) @?= [("demo", UserScope)]+ (kept ^. #updated) @?= []+ mine <- BS.readFile (claudeSkill </> "SKILL.md")+ mine @?= "my edits"+ forced <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") OverwriteLocalEdits+ (forced ^. #updated) @?= [("demo", UserScope)]+ (forced ^. #skipped) @?= []+ fresh <- BS.readFile (claudeSkill </> "SKILL.md")+ fresh @?= "new upstream"+ -- A sidecar from before this release records no installed hash,+ -- so the item is reinstalled without the check.+ BS.writeFile (claudeSkill </> ".testkit-kit.json") legacySidecarJson+ BS.writeFile (claudeSkill </> "SKILL.md") "my edits again"+ legacy <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits+ (legacy ^. #updated) @?= [("demo", UserScope)]+ (legacy ^. #skipped) @?= [],+ testCase "a write failure during update is returned, not thrown" $+ withPreparedKitHome $ \home _cache -> do+ let claudeAgents = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "agents"+ cache = home </> ".cache" </> "testkit" </> "kit"+ _ <- assertRight =<< installItem testConfig "reviewer" UserScope (InstallOptions Nothing True)+ removeFile (claudeAgents </> "reviewer.md")+ createDirectoryIfMissing True (claudeAgents </> "reviewer.md")+ manifest <- assertRight =<< loadManifest cache+ result <- reinstallPresent testConfig cache manifest (Just "reviewer") OverwriteLocalEdits+ assertKitError "KitWriteFailed" isWriteFailed result+ ]++decodeFixture :: FilePath -> IO KitManifest+decodeFixture file = do+ bytes <- BS.readFile ("test/fixtures" </> file)+ case Aeson.eitherDecodeStrict' bytes of+ Right manifest -> pure manifest+ Left err -> assertFailure ("failed to decode " <> file <> ": " <> err)++mkSidecar :: Maybe Text -> Text -> SidecarMeta+mkSidecar mVersion h =+ SidecarMeta+ { name = "foo",+ kind = "skill",+ version = mVersion,+ hash = h,+ installedAt = "2026-05-13T00:00:00Z",+ installedFiles = Nothing,+ installedHash = Nothing,+ visibility = Nothing,+ visibilitySource = Nothing,+ sharedLinks = Nothing,+ codexDisabledSkills = Nothing+ }++mkSkillItem :: Text -> Maybe Text -> KitItem+mkSkillItem n mVersion =+ KitSkillItem+ SkillEntry+ { name = n,+ description = "x",+ version = mVersion,+ path = "skills/foo",+ files = ["SKILL.md"],+ visibility = Nothing+ }++mkAgentItem :: Text -> Maybe Text -> KitItem+mkAgentItem n mVersion =+ KitAgentItem+ AgentEntry+ { name = n,+ description = "x",+ version = mVersion,+ path = "agents/foo.md",+ files = Nothing,+ visibility = Nothing+ }++jsonTests :: TestTree+jsonTests =+ testGroup+ "JSON"+ [ testCase "kit-status document matches the golden" $+ withStatusFixture $ \home proj config -> do+ document <- statusDocument <$> kitStatus config+ golden "status.json" (normalise home proj document),+ testCase "kit-list document matches the golden" $+ withStatusFixture $ \home proj config -> do+ manifest <- assertRight =<< loadManifest (fixtureCache home)+ copies <- installedCopies config+ golden "list.json" (normalise home proj (listDocument (UpstreamStale "x") manifest copies)),+ testCase "kit-update document matches the golden" $+ withPreparedKitHome $ \home cache -> do+ let config = testConfig & #projectRoot .~ pure (home </> "project")+ _ <- assertRight =<< installItem config "demo" UserScope defaultInstallOptions+ _ <- assertRight =<< installItem config "reviewer" UserScope (InstallOptions Nothing True)+ BS.writeFile (home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo" </> "SKILL.md") "my edits"+ manifest <- assertRight =<< loadManifest cache+ report <- assertRight =<< reinstallPresent config cache manifest Nothing KeepLocalEdits+ golden "update.json" (updateDocument report)+ jsonKey "refresh" (updateDocument (report & #refresh .~ Just RepoPulled)) @?= Just (String "pulled"),+ testCase "every document names its format version" $+ withStatusFixture $ \home _proj config -> do+ manifest <- assertRight =<< loadManifest (fixtureCache home)+ copies <- installedCopies config+ statusDoc <- statusDocument <$> kitStatus config+ report <- assertRight =<< reinstallPresent config (fixtureCache home) manifest (Just "no-such-item") KeepLocalEdits+ let documents =+ [ ("kit-list", listDocument UpstreamReady manifest copies),+ ("kit-status", statusDoc),+ ("kit-update", updateDocument report)+ ]+ forM_ documents $ \(name, document) -> do+ jsonKey "formatVersion" document @?= Just (Aeson.toJSON kitJsonFormatVersion)+ jsonKey "document" document @?= Just (String name),+ testCase "status --json parses as one document when the cache is stale" $+ withPreparedKitHome $ \_home _cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ (result, out) <- captureStdout (runKitCommand testConfig (KitStatus JsonOutput))+ result @?= Right ()+ document <- decodeDocument out+ (jsonKey "upstream" document >>= jsonKey "state") @?= Just (String "stale"),+ testCase "status --json parses as one document when the repository is unreachable" $+ withFreshHome $ \_home -> do+ let config = testConfig & #repoUrl .~ "file:///nonexistent-kit"+ (result, out) <- captureStdout (runKitCommand config (KitStatus JsonOutput))+ result @?= Right ()+ document <- decodeDocument out+ (jsonKey "upstream" document >>= jsonKey "state") @?= Just (String "unavailable")+ jsonKey "items" document @?= Just (Array mempty),+ testCase "list --json writes nothing to stdout when the repository is unreachable" $+ withFreshHome $ \_home -> do+ let config = testConfig & #repoUrl .~ "file:///nonexistent-kit"+ (result, out) <- captureStdout (runKitCommand config (KitList JsonOutput))+ assertKitError "KitCloneFailed" isCloneFailed result+ out @?= "",+ testCase "list --json keeps the first-clone notice off stdout" $+ withFreshHome $ \home -> do+ let repoDir = takeDirectory home </> "kit-repo"+ createDirectoryIfMissing True (repoDir </> "skills" </> "demo")+ createDirectoryIfMissing True (repoDir </> "agents")+ BS.writeFile (repoDir </> "skills" </> "demo" </> "SKILL.md") "skill instructions\n"+ BS.writeFile (repoDir </> "agents" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"+ BS.writeFile (repoDir </> "kit.json") manifestJson+ git repoDir ["init", "--quiet"]+ git repoDir ["add", "."]+ git repoDir ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "--quiet", "-m", "kit"]+ let config = testConfig & #repoUrl .~ ("file://" <> Text.pack repoDir)+ (result, out) <- captureStdout (runKitCommand config (KitList JsonOutput))+ result @?= Right ()+ document <- decodeDocument out+ (jsonKey "upstream" document >>= jsonKey "state") @?= Just (String "ready")+ case jsonKey "items" document of+ Just (Array items) -> map (jsonKey "name") (toList items) @?= [Just (String "demo"), Just (String "reviewer")]+ other -> assertFailure ("expected an items array, got " <> show other),+ testCase "update --json writes nothing to stdout when the pull fails" $+ withPreparedKitHome $ \_home _cache -> do+ (result, out) <- captureStdout (runKitCommand testConfig (KitUpdate Nothing KeepLocalEdits JsonOutput))+ assertKitError "KitPullFailed" isPullFailed result+ out @?= "",+ testCase "the command and the encoder agree" $+ withStatusFixture $ \home proj config -> do+ (result, out) <- captureStdout (runKitCommand config (KitStatus JsonOutput))+ result @?= Right ()+ document <- decodeDocument out+ golden "status.json" (normalise home proj document),+ testCase "--json parses on list, status, and update only" $ do+ let parse = getParseResult . execParserPure defaultPrefs (info (kitCommandParser testConfig) mempty)+ parse ["list", "--json"] @?= Just (KitList JsonOutput)+ parse ["status", "--json"] @?= Just (KitStatus JsonOutput)+ parse ["update", "--json"] @?= Just (KitUpdate Nothing KeepLocalEdits JsonOutput)+ parse ["install", "demo", "--json"] @?= Nothing+ ]+ where+ isCloneFailed = \case+ KitCloneFailed _ _ -> True+ _ -> False+ git dir args = do+ (code, _out, err) <- readProcessWithExitCode "git" ("-C" : dir : args) ""+ assertBool ("git " <> unwords args <> " failed: " <> err) (code == ExitSuccess)+ decodeDocument out = case Aeson.eitherDecodeStrict' out of+ Right document -> pure document+ Left err -> assertFailure ("stdout is not one JSON document (" <> err <> "): " <> show out)++-- | A temporary, empty @HOME@ for the duration of the action.+withFreshHome :: (FilePath -> IO a) -> IO a+withFreshHome action =+ withSystemTempDirectory "baikai-kit-fresh" $ \tmp -> do+ oldHome <- lookupEnv "HOME"+ oldCodexHome <- lookupEnv "CODEX_HOME"+ let home = tmp </> "home"+ createDirectoryIfMissing True home+ setEnv "HOME" home+ setEnv "CODEX_HOME" (home </> ".codex")+ action home `finally` (restoreHome oldHome >> restoreCodexHome oldCodexHome)++fixtureCache :: FilePath -> FilePath+fixtureCache home = home </> ".cache" </> "testkit" </> "kit"++-- | Seven items covering every status condition, both kinds, both scopes,+-- and both providers. The action gets @HOME@, the project root, and a+-- config whose project scope is that root.+withStatusFixture :: (FilePath -> FilePath -> KitConfig -> IO a) -> IO a+withStatusFixture action =+ withPreparedKitHome $ \home cache -> do+ let proj = takeDirectory home </> "project"+ config = testConfig & #projectRoot .~ pure proj+ skills = ["alpha", "beta", "gamma", "delta", "epsilon"]+ agents = ["reviewer", "planner"]+ claudeBase = home </> ".config" </> "testkit" </> "agents" </> ".claude"+ createDirectoryIfMissing True proj+ forM_ skills $ \n -> do+ createDirectoryIfMissing True (cache </> "skills" </> n)+ BS.writeFile (cache </> "skills" </> n </> "SKILL.md") ("the " <> Text.Encoding.encodeUtf8 (Text.pack n) <> " skill\n")+ forM_ agents $ \n ->+ BS.writeFile (cache </> "agents" </> (n <> ".md")) ("---\nname: " <> Text.Encoding.encodeUtf8 (Text.pack n) <> "\n---\nBe helpful.\n")+ BS.writeFile (cache </> "kit.json") (fixtureManifest [] [])+ forM_ ["alpha", "gamma", "epsilon", "reviewer"] $ \n ->+ void (assertRight =<< installItem config n UserScope (InstallOptions Nothing True))+ forM_ ["beta", "delta", "planner"] $ \n ->+ void (assertRight =<< installItem config n ProjectScope (InstallOptions Nothing True))+ -- alpha: the Codex copy loses its sidecar => unknown.+ removeFile (home </> ".agents" </> "skills" </> "alpha" </> ".testkit-kit.json")+ -- gamma: its Claude shared link is lost; the Codex copy predates visibility.+ removeFile (home </> ".claude/skills/gamma")+ let gammaSidecar = home </> ".agents/skills/gamma/.testkit-kit.json"+ gammaMeta <- requireSidecar gammaSidecar+ LBS.writeFile gammaSidecar (Aeson.encode (gammaMeta & #visibility .~ Nothing & #visibilitySource .~ Nothing))+ -- gamma: upstream sources change without a version bump => changed-upstream.+ BS.writeFile (cache </> "skills" </> "gamma" </> "SKILL.md") "the gamma skill, revised\n"+ -- epsilon: upstream now lists a file through a symbolic link => refused.+ let outsideDir = takeDirectory home </> "outside"+ createDirectoryIfMissing True outsideDir+ BS.writeFile (outsideDir </> "secret.txt") "top secret\n"+ createDirectoryLink outsideDir (cache </> "skills" </> "epsilon" </> "sub")+ -- reviewer: the Claude copy is edited => modified.+ BS.writeFile (claudeBase </> "agents" </> "reviewer.md") "my own reviewer\n"+ -- planner: the Claude sidecar predates the installed-file hash => edits-unknown.+ let plannerSidecar = proj </> ".testkit" </> "agents" </> ".claude" </> "agents" </> "planner.testkit-kit.json"+ sidecar <- maybe (assertFailure "expected the planner sidecar") pure =<< readSidecar plannerSidecar+ LBS.writeFile plannerSidecar (Aeson.encode (sidecar & #installedFiles .~ Nothing & #installedHash .~ Nothing))+ -- beta: version bump => outdated; delta: removed => delisted.+ BS.writeFile (cache </> "kit.json") (fixtureManifest ["beta"] ["delta"])+ action home proj config++-- | The fixture manifest: every item at 0.1.0 except those in @bumped@+-- (0.2.0), without those in @removed@; once anything is bumped, epsilon+-- also lists a file below its symlinked @sub@ directory.+fixtureManifest :: [Text] -> [Text] -> BS.ByteString+fixtureManifest bumped removed =+ Text.Encoding.encodeUtf8 $+ "{\"version\":2,\"skills\":["+ <> Text.intercalate "," [skill n | n <- ["alpha", "beta", "gamma", "delta", "epsilon"], n `notElem` removed]+ <> "],\"agents\":["+ <> Text.intercalate "," [agent n | n <- ["reviewer", "planner"], n `notElem` removed]+ <> "]}"+ where+ final = not (null bumped)+ version n = if n `elem` bumped then "0.2.0" else "0.1.0"+ files n+ | n == "epsilon" && final = "[\"SKILL.md\",\"sub/secret.txt\"]"+ | otherwise = "[\"SKILL.md\"]"+ skill n =+ "{\"name\":\""+ <> n+ <> "\",\"description\":\"The "+ <> n+ <> " skill\",\"version\":\""+ <> version n+ <> "\",\"path\":\"skills/"+ <> n+ <> "\",\"files\":"+ <> files n+ <> (if n `elem` ["alpha", "gamma"] then ",\"visibility\":\"shared\"" else "")+ <> "}"+ agent n =+ "{\"name\":\""+ <> n+ <> "\",\"description\":\"The "+ <> n+ <> " agent\",\"version\":\""+ <> version n+ <> "\",\"path\":\"agents/"+ <> n+ <> ".md\"}"++-- | Run an action with stdout sent to a file, and return what it wrote.+-- The pause first lets tasty's reporter finish writing the test name,+-- which it does on stdout from another thread as the test starts.+captureStdout :: IO a -> IO (a, BS.ByteString)+captureStdout action =+ withSystemTempFile "baikai-kit-stdout" $ \file fileHandle -> do+ threadDelay 200000+ hFlush stdout+ saved <- hDuplicate stdout+ hDuplicateTo fileHandle stdout+ result <-+ action `finally` do+ hFlush stdout+ hDuplicateTo saved stdout+ hClose saved+ hClose fileHandle+ out <- BS.readFile file+ pure (result, out)++-- | Replace the temporary directories in every string with @$HOME@ and+-- @$PROJECT@, and any upstream detail (git's message, which names paths+-- and varies by git version) with @<detail>@.+normalise :: FilePath -> FilePath -> Value -> Value+normalise home proj = go+ where+ go = \case+ String t -> String (Text.replace (Text.pack proj) "$PROJECT" (Text.replace (Text.pack home) "$HOME" t))+ Array values -> Array (fmap go values)+ Object o -> Object (KeyMap.mapWithKey (\key value -> if key == "upstream" then upstream value else go value) o)+ other -> other+ upstream = \case+ Object o -> Object (KeyMap.mapWithKey (\key value -> if key == "detail" && value /= Null then String "<detail>" else value) o)+ other -> other++-- | Compare with @test/golden/<file>@, or write it when+-- @BAIKAI_KIT_ACCEPT_GOLDEN@ is set. Values are compared decoded, so key+-- order and whitespace are not part of the contract.+golden :: FilePath -> Value -> Assertion+golden file value = do+ let path = "test" </> "golden" </> file+ accept <- lookupEnv "BAIKAI_KIT_ACCEPT_GOLDEN"+ case accept of+ Just _ -> do+ createDirectoryIfMissing True ("test" </> "golden")+ LBS.writeFile path (Aeson.encode value <> "\n")+ Nothing -> do+ expected <- Aeson.eitherDecodeFileStrict' path+ case expected of+ Left err -> assertFailure ("cannot read golden " <> path <> ": " <> err)+ Right expectedValue ->+ assertBool+ ("golden " <> path <> " differs.\nexpected: " <> show (Aeson.encode expectedValue) <> "\nactual: " <> show (Aeson.encode value))+ (expectedValue == (value :: Value))++jsonKey :: Aeson.Key -> Value -> Maybe Value+jsonKey key = \case+ Object o -> KeyMap.lookup key o+ _ -> Nothing++commandTests :: TestTree+commandTests =+ testGroup+ "Command"+ [ testCase "install parses with and without a name" $ do+ parse ["install"] @?= Just (KitInstall Nothing UserScope defaultInstallOptions)+ parse ["install", "demo", "--project"] @?= Just (KitInstall (Just "demo") ProjectScope defaultInstallOptions)+ parse [] @?= Just (KitList HumanOutput),+ testCase "install help names the tool's project directory" $+ case execParserPure defaultPrefs (info (kitCommandParser testConfig <**> helper) mempty) ["install", "--help"] of+ Failure failure -> do+ let rendered = fst (renderFailure failure "kit")+ assertBool ("expected .testkit/agents in help:\n" <> rendered) (".testkit/agents" `isInfixOf` rendered)+ _ -> assertFailure "expected --help to produce help text",+ testCase "install with no name and no chooser is a KitItemNameRequired error" $ do+ result <- runKitCommand testConfig (KitInstall Nothing UserScope defaultInstallOptions)+ result @?= Left KitItemNameRequired+ exitResult <- try @ExitCode (runKit testConfig (KitInstall Nothing UserScope defaultInstallOptions))+ exitResult @?= Left (ExitFailure 1),+ testCase "install with no name installs what the chooser returns" $+ withPreparedKitHome $ \home _cache -> do+ seen <- newIORef []+ let chooser manifest = do+ writeIORef seen (map (view #name) (manifest ^. #skills) ++ map (view #name) (manifest ^. #agents))+ pure (Just "demo")+ config = testConfig & #chooseItem .~ Just chooser+ result <- runKitCommand config (KitInstall Nothing UserScope defaultInstallOptions)+ result @?= Right ()+ assertFileExists (userClaudeDemo home </> "SKILL.md")+ readIORef seen >>= (@?= ["demo", "reviewer"]),+ testCase "a cancelled choice installs nothing and succeeds" $+ withPreparedKitHome $ \home _cache -> do+ let config = testConfig & #chooseItem .~ Just (\_ -> pure Nothing)+ result <- runKitCommand config (KitInstall Nothing UserScope defaultInstallOptions)+ result @?= Right ()+ assertDirectoryMissing (userClaudeDemo home)+ exitResult <- try @ExitCode (runKit config (KitInstall Nothing UserScope defaultInstallOptions))+ exitResult @?= Right ()+ assertDirectoryMissing (userClaudeDemo home),+ testCase "a chosen name the manifest lacks is KitItemNotFound" $+ withPreparedKitHome $ \_home _cache -> do+ let config = testConfig & #chooseItem .~ Just (\_ -> pure (Just "nope"))+ result <- runKitCommand config (KitInstall Nothing UserScope defaultInstallOptions)+ result @?= Left (KitItemNotFound "nope")+ ]+ where+ parse = getParseResult . execParserPure defaultPrefs (info (kitCommandParser testConfig) mempty)+ userClaudeDemo home = home </> ".config" </> "testkit" </> "agents" </> ".claude" </> "skills" </> "demo"++projectRootTests :: TestTree+projectRootTests =+ testGroup+ "Project root"+ [ testCase "findProjectRoot walks up from a nested directory" $+ withMarkedTree $ \root -> do+ found <- findProjectRoot [rootMarker] (root </> "a" </> "b")+ found @?= Just root,+ testCase "findProjectRoot accepts a start directory that is itself the root" $+ withMarkedTree $ \root -> do+ found <- findProjectRoot [rootMarker] root+ found @?= Just root,+ testCase "findProjectRoot returns Nothing when no marker exists" $+ withMarkedTree $ \root -> do+ found <- findProjectRoot [".testkit-no-such-marker"] (root </> "a")+ found @?= Nothing+ withCurrentDirectory (root </> "a") $ do+ resolved <- projectRootByMarkers [".testkit-no-such-marker"]+ cwd <- getCurrentDirectory+ resolved @?= cwd,+ testCase "a configured root puts project scope in one place" $+ withPreparedKitHome $ \_home _cache ->+ withProjectTree $ \proj -> do+ let config = testConfig & #projectRoot .~ pure proj+ claudeSkill = proj </> ".testkit" </> "agents" </> ".claude" </> "skills" </> "demo"+ withCurrentDirectory (proj </> "src" </> "deep") $+ void (assertRight =<< installItem config "demo" ProjectScope defaultInstallOptions)+ assertFileExists (claudeSkill </> "SKILL.md")+ assertFileExists (proj </> ".agents" </> "skills" </> "demo" </> "SKILL.md")+ assertDirectoryMissing (proj </> "src" </> "deep" </> ".testkit")+ withCurrentDirectory (proj </> "docs") $ do+ assertProjectRow config+ dirs <- agentDirsForSession config+ assertBool+ ("expected the project agents dir in " <> show dirs)+ ((proj </> ".testkit" </> "agents") `elem` dirs)+ outcomes <- assertRight =<< uninstallItem config "demo" ProjectScope+ let rendered = renderUninstallReport "demo" ProjectScope outcomes+ assertBool+ ("unexpected uninstall report: " <> Text.unpack rendered)+ ("Uninstalled skill 'demo' from project scope" `Text.isPrefixOf` rendered)+ assertDirectoryMissing claudeSkill+ let markerConfig = testConfig & #projectRoot .~ projectRootByMarkers [rootMarker]+ withCurrentDirectory (proj </> "src" </> "deep") $+ void (assertRight =<< installItem markerConfig "demo" ProjectScope defaultInstallOptions)+ assertFileExists (claudeSkill </> "SKILL.md")+ withCurrentDirectory (proj </> "docs") $ assertProjectRow markerConfig,+ testCase "without a resolver, project scope is the current directory" $+ withPreparedKitHome $ \_home _cache ->+ withProjectTree $ \proj ->+ withCurrentDirectory (proj </> "src" </> "deep") $ do+ _ <- assertRight =<< installItem testConfig "demo" ProjectScope defaultInstallOptions+ cwd <- getCurrentDirectory+ assertFileExists (cwd </> ".testkit" </> "agents" </> ".claude" </> "skills" </> "demo" </> "SKILL.md")+ assertDirectoryMissing (proj </> ".testkit")+ ]+ where+ assertProjectRow config = do+ report <- kitStatus config+ let projectRows = filter (\row -> row ^. #name == "demo" && row ^. #scope == "project") (report ^. #rows)+ assertBool "expected a project-scope status row for demo" (not (null projectRows))++-- | A marker no real directory above the system temporary directory can+-- hold, so a walk to the filesystem root cannot find someone's @.git@.+rootMarker :: FilePath+rootMarker = ".testkit-root-marker"++-- | @root/.testkit-root-marker@ and @root/a/b@, with @root@ canonical so+-- it compares equal to paths the resolver builds.+withMarkedTree :: (FilePath -> IO a) -> IO a+withMarkedTree action =+ withSystemTempDirectory "baikai-kit-root" $ \tmp -> do+ root <- (</> "root") <$> canonicalizePath tmp+ createDirectoryIfMissing True (root </> "a" </> "b")+ BS.writeFile (root </> rootMarker) ""+ action root++-- | A project with a root marker, @src/deep@, and @docs@.+withProjectTree :: (FilePath -> IO a) -> IO a+withProjectTree action =+ withSystemTempDirectory "baikai-kit-project" $ \tmp -> do+ proj <- (</> "proj") <$> canonicalizePath tmp+ createDirectoryIfMissing True (proj </> "src" </> "deep")+ createDirectoryIfMissing True (proj </> "docs")+ BS.writeFile (proj </> rootMarker) ""+ action proj++testConfig :: KitConfig+testConfig = kitConfig "testkit" "file:///not-used" [InteractiveClaude, InteractiveCodex]++withPreparedKitHome :: (FilePath -> FilePath -> IO a) -> IO a+withPreparedKitHome action =+ withSystemTempDirectory "baikai-kit-home" $ \tmp -> do+ oldHome <- lookupEnv "HOME"+ oldCodexHome <- lookupEnv "CODEX_HOME"+ let home = tmp </> "home"+ cache = home </> ".cache" </> "testkit" </> "kit"+ createDirectoryIfMissing True (cache </> ".git")+ createDirectoryIfMissing True (cache </> "skills" </> "demo")+ createDirectoryIfMissing True (cache </> "agents")+ BS.writeFile (cache </> "skills" </> "demo" </> "SKILL.md") "skill instructions\n"+ BS.writeFile (cache </> "agents" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"+ BS.writeFile (cache </> "kit.json") manifestJson+ setEnv "HOME" home+ setEnv "CODEX_HOME" (home </> ".codex")+ action home cache `finally` (restoreHome oldHome >> restoreCodexHome oldCodexHome)++restoreCodexHome :: Maybe String -> IO ()+restoreCodexHome Nothing = unsetEnv "CODEX_HOME"+restoreCodexHome (Just value) = setEnv "CODEX_HOME" value++restoreHome :: Maybe String -> IO ()+restoreHome Nothing = unsetEnv "HOME"+restoreHome (Just value) = setEnv "HOME" value++-- | Plant a committed-symlink kit: a directory link out of the checkout+-- and a manifest that lists a file below it.+plantSymlinkedSource :: FilePath -> FilePath -> IO ()+plantSymlinkedSource home cache = do+ let outsideDir = takeDirectory home </> "outside"+ createDirectoryIfMissing True outsideDir+ BS.writeFile (outsideDir </> "secret.txt") "top secret\n"+ createDirectoryLink outsideDir (cache </> "skills" </> "demo" </> "sub")+ BS.writeFile (cache </> "kit.json") manifestWithSymlinkedFileJson++manifestWithSymlinkedFileJson :: BS.ByteString+manifestWithSymlinkedFileJson =+ Text.Encoding.encodeUtf8 $+ Text.concat+ [ "{\"version\":2,",+ "\"skills\":[{",+ "\"name\":\"demo\",",+ "\"description\":\"Demo skill\",",+ "\"version\":\"0.1.0\",",+ "\"path\":\"skills/demo\",",+ "\"files\":[\"SKILL.md\",\"sub/secret.txt\"]",+ "}],",+ "\"agents\":[]} "+ ]++-- | Rewrite the fixture kit so its agent lists two files below a+-- directory of its own.+plantMultiFileAgent :: FilePath -> IO ()+plantMultiFileAgent cache = do+ createDirectoryIfMissing True (cache </> "agents" </> "reviewer")+ BS.writeFile (cache </> "agents" </> "reviewer" </> "reviewer.md") "---\nname: reviewer\n---\nReview carefully.\n"+ BS.writeFile (cache </> "agents" </> "reviewer" </> "guide.md") "How to review.\n"+ BS.writeFile (cache </> "kit.json") manifestWithMultiFileAgentJson++manifestWithMultiFileAgentJson :: BS.ByteString+manifestWithMultiFileAgentJson =+ Text.Encoding.encodeUtf8 $+ Text.concat+ [ "{\"version\":2,",+ "\"skills\":[],",+ "\"agents\":[{",+ "\"name\":\"reviewer\",",+ "\"description\":\"Review agent\",",+ "\"version\":\"0.1.0\",",+ "\"path\":\"agents/reviewer\",",+ "\"files\":[\"reviewer.md\",\"guide.md\"]",+ "}]} "+ ]++manifestWithUnsupportedVersionJson :: BS.ByteString+manifestWithUnsupportedVersionJson =+ Text.Encoding.encodeUtf8 $+ Text.concat+ [ "{\"version\":99,",+ "\"skills\":[{",+ "\"name\":\"demo\",",+ "\"description\":\"Demo skill\",",+ "\"version\":\"0.1.0\",",+ "\"path\":\"skills/demo\",",+ "\"files\":[\"SKILL.md\"]",+ "}],",+ "\"agents\":[]} "+ ]++legacySidecarJson :: BS.ByteString+legacySidecarJson =+ "{\"name\":\"demo\",\"kind\":\"skill\",\"version\":\"0.1.0\",\"hash\":\"sha256:x\",\"installedAt\":\"t\"}"++manifestJson :: BS.ByteString+manifestJson =+ Text.Encoding.encodeUtf8 $+ Text.concat+ [ "{\"version\":2,",+ "\"skills\":[{",+ "\"name\":\"demo\",",+ "\"description\":\"Demo skill\",",+ "\"version\":\"0.1.0\",",+ "\"path\":\"skills/demo\",",+ "\"files\":[\"SKILL.md\"]",+ "}],",+ "\"agents\":[{",+ "\"name\":\"reviewer\",",+ "\"description\":\"Review agent\",",+ "\"version\":\"0.1.0\",",+ "\"path\":\"agents/reviewer.md\"",+ "}]} "+ ]++manifestWithoutDemoJson :: BS.ByteString+manifestWithoutDemoJson =+ Text.Encoding.encodeUtf8 $+ Text.concat+ [ "{\"version\":2,",+ "\"skills\":[],",+ "\"agents\":[{",+ "\"name\":\"reviewer\",",+ "\"description\":\"Review agent\",",+ "\"version\":\"0.1.0\",",+ "\"path\":\"agents/reviewer.md\"",+ "}]} "+ ]++manifestWithDemoVersionJson :: BS.ByteString+manifestWithDemoVersionJson =+ Text.Encoding.encodeUtf8 $+ Text.concat+ [ "{\"version\":2,",+ "\"skills\":[{",+ "\"name\":\"demo\",",+ "\"description\":\"Demo skill\",",+ "\"version\":\"0.2.0\",",+ "\"path\":\"skills/demo\",",+ "\"files\":[\"SKILL.md\"]",+ "}],",+ "\"agents\":[{",+ "\"name\":\"reviewer\",",+ "\"description\":\"Review agent\",",+ "\"version\":\"0.1.0\",",+ "\"path\":\"agents/reviewer.md\"",+ "}]} "+ ]++maliciousManifestJson :: BS.ByteString+maliciousManifestJson =+ Text.Encoding.encodeUtf8 $+ Text.concat+ [ "{\"version\":2,",+ "\"skills\":[{",+ "\"name\":\"evil\",",+ "\"description\":\"Evil skill\",",+ "\"version\":\"0.1.0\",",+ "\"path\":\"skills/demo\",",+ "\"files\":[\"../../../../escape.txt\"]",+ "}],",+ "\"agents\":[]} "+ ]++isUnsafePath :: KitError -> Bool+isUnsafePath = \case KitUnsafePath _ _ -> True; _ -> False++isUnsafeName :: KitError -> Bool+isUnsafeName = \case KitUnsafeName _ _ -> True; _ -> False++isSourceSymlink :: KitError -> Bool+isSourceSymlink = \case KitSourceSymlink _ -> True; _ -> False++isWriteFailed :: KitError -> Bool+isWriteFailed = \case KitWriteFailed {} -> True; _ -> False++isPullFailed :: KitError -> Bool+isPullFailed = \case KitPullFailed _ -> True; _ -> False++isManifestMissing :: KitError -> Bool+isManifestMissing = \case KitManifestMissing _ -> True; _ -> False++isVersionUnsupported :: KitError -> Bool+isVersionUnsupported = \case KitManifestVersionUnsupported _ _ -> True; _ -> False++isManifestInvalid :: KitError -> Bool+isManifestInvalid = \case KitManifestInvalid _ _ -> True; _ -> False++assertKitError :: (Show a) => String -> (KitError -> Bool) -> Either KitError a -> IO ()+assertKitError label matches result =+ case result of+ Left err | matches err -> pure ()+ other -> assertFailure ("expected " <> label <> ", got " <> show other)++assertRight :: (Show e) => Either e a -> IO a+assertRight = \case+ Right value -> pure value+ Left err -> assertFailure ("expected Right, got Left " <> show err)++assertLeft :: (Show b) => Either a b -> IO ()+assertLeft result =+ case result of+ Left _ -> pure ()+ Right value -> assertFailure ("expected Left, got Right " <> show value)++assertFileExists :: FilePath -> IO ()+assertFileExists path = do+ exists <- doesFileExist path+ assertBool ("expected file to exist: " <> path) exists++assertFileMissing :: FilePath -> IO ()+assertFileMissing path = do+ exists <- doesFileExist path+ assertBool ("expected file to be missing: " <> path) (not exists)++assertDirectoryMissing :: FilePath -> IO ()+assertDirectoryMissing path = do+ exists <- doesDirectoryExist path+ assertBool ("expected directory to be missing: " <> path) (not exists)++assertDirectoryExists :: FilePath -> IO ()+assertDirectoryExists path = do+ exists <- doesDirectoryExist path+ assertBool ("expected directory to exist: " <> path) exists++findFilesWithSuffix :: FilePath -> String -> IO [FilePath]+findFilesWithSuffix root suffix = do+ exists <- doesPathExist root+ if not exists+ then pure []+ else do+ isDir <- doesDirectoryExist root+ if not isDir+ then pure [root | suffix `isSuffixOf` root]+ else do+ names <- listDirectory root+ fmap concat $ mapM (\name -> findFilesWithSuffix (root </> name) suffix) names++findOutcome :: InteractiveProvider -> [RemovalOutcome] -> RemovalOutcome+findOutcome expected outcomes =+ case find ((== expected) . view #provider) outcomes of+ Just outcome -> outcome+ Nothing -> error "expected provider outcome"++visibilityTests :: TestTree+visibilityTests =+ testGroup+ "visibility"+ [ testCase "a manifest item without visibility installs tool-only" $+ withPreparedKitHome $ \home _ -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ assertFileExists (ownedDemo home </> "SKILL.md")+ assertDirectoryMissing (sharedDemo home)+ meta <- demoSidecar home+ meta ^. #visibility @?= Just "tool-only"+ meta ^. #visibilitySource @?= Just "manifest",+ testCase "a shared item links into ~/.claude/skills" $+ withPreparedKitHome $ \home cache -> do+ declareShared cache+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ getSymbolicLinkTarget (sharedDemo home) >>= (@?= ownedDemo home)+ BS.readFile (sharedDemo home </> "SKILL.md") >>= (@?= "skill instructions\n")+ meta <- demoSidecar home+ meta ^. #sharedLinks @?= Just [Text.pack (sharedDemo home)],+ testCase "a project-scope shared item uses a relative link" $+ withPreparedKitHome $ \home _ -> do+ let root = takeDirectory home </> "project"+ config = testConfig & #projectRoot .~ pure root+ _ <- assertRight =<< installItem config "demo" ProjectScope sharedOptions+ getSymbolicLinkTarget (root </> ".claude/skills/demo") >>= (@?= "../../.testkit/agents/.claude/skills/demo"),+ testCase "relativeLinkTarget handles siblings and descendants" $ do+ relativeLinkTarget "/a/.claude/skills" "/a/.tool/agents/.claude/skills/demo" @?= "../../.tool/agents/.claude/skills/demo"+ relativeLinkTarget "/a" "/a/b/c" @?= "b/c",+ testCase "kit install --shared overrides the manifest and update keeps it" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+ updateDemo cache+ assertBool "shared link survives" =<< pathIsSymbolicLink (sharedDemo home)+ meta <- demoSidecar home+ meta ^. #visibilitySource @?= Just "install-flag",+ testCase "update follows a changed manifest default" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ declareShared cache+ updateDemo cache+ assertBool "new manifest creates link" =<< pathIsSymbolicLink (sharedDemo home),+ testCase "update changes linked content without touching the link" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+ before <- getSymbolicLinkTarget (sharedDemo home)+ inodeBefore <- Posix.getSymbolicLinkStatus (sharedDemo home)+ BS.writeFile (cache </> "skills/demo/SKILL.md") "new content\n"+ updateDemo cache+ getSymbolicLinkTarget (sharedDemo home) >>= (@?= before)+ inodeAfter <- Posix.getSymbolicLinkStatus (sharedDemo home)+ Posix.fileID inodeAfter @?= Posix.fileID inodeBefore+ Posix.modificationTimeHiRes inodeAfter @?= Posix.modificationTimeHiRes inodeBefore+ BS.readFile (sharedDemo home </> "SKILL.md") >>= (@?= "new content\n"),+ testCase "update recreates a deleted link including when local edits skip content" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+ removeFile (sharedDemo home)+ updateDemo cache+ assertBool "link repaired" =<< pathIsSymbolicLink (sharedDemo home)+ BS.writeFile (ownedDemo home </> "SKILL.md") "local edits\n"+ removeFile (sharedDemo home)+ manifest <- assertRight =<< loadManifest cache+ report <- assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits+ report ^. #skipped @?= [("demo", UserScope)]+ BS.readFile (sharedDemo home </> "SKILL.md") >>= (@?= "local edits\n"),+ testCase "uninstall removes the link and nothing else" $+ withPreparedKitHome $ \home _ -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+ createDirectoryIfMissing True (home </> ".claude/skills/other")+ outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope+ concatMap (view #linksRemoved) outcomes @?= [sharedDemo home]+ assertDirectoryExists (home </> ".claude/skills/other")+ assertDirectoryMissing (sharedDemo home),+ testCase "a shared install refuses a foreign skill directory before any write" $+ withPreparedKitHome $ \home _ -> do+ createDirectoryIfMissing True (sharedDemo home)+ BS.writeFile (sharedDemo home </> "SKILL.md") "foreign\n"+ result <- installItem testConfig "demo" UserScope sharedOptions+ assertKitError "shared name taken" isNameTaken result+ BS.readFile (sharedDemo home </> "SKILL.md") >>= (@?= "foreign\n")+ assertDirectoryMissing (ownedDemo home)+ assertDirectoryMissing (home </> ".agents/skills/demo"),+ testCase "a shared install names the owning tool even for a dangling link" $+ withPreparedKitHome $ \home _ -> do+ createDirectoryIfMissing True (home </> ".claude/skills")+ createDirectoryLink (home </> ".config/rei/agents/.claude/skills/demo") (sharedDemo home)+ result <- installItem testConfig "demo" UserScope sharedOptions+ case result of+ Left (KitSharedNameTaken _ owner) -> owner @?= Just "rei"+ other -> assertFailure (show other),+ testCase "a Codex install refuses a skill directory without this tool's sidecar" $+ withPreparedKitHome $ \home _ -> do+ let path = home </> ".agents/skills/demo"+ createDirectoryIfMissing True path+ BS.writeFile (path </> "SKILL.md") "foreign\n"+ result <- installItem testConfig "demo" UserScope defaultInstallOptions+ assertKitError "shared name taken" isNameTaken result+ BS.readFile (path </> "SKILL.md") >>= (@?= "foreign\n")+ assertDirectoryMissing (ownedDemo home),+ testCase "the parser accepts --shared, --tool-only and --accept-shared-codex" $ do+ let parse = getParseResult . execParserPure defaultPrefs (info (kitCommandParser testConfig) mempty)+ parse ["install", "demo"] @?= Just (KitInstall (Just "demo") UserScope defaultInstallOptions)+ parse ["install", "demo", "--shared"] @?= Just (KitInstall (Just "demo") UserScope sharedOptions)+ parse ["install", "demo", "--tool-only"] @?= Just (KitInstall (Just "demo") UserScope (InstallOptions (Just ToolOnlyVisibility) False))+ parse ["install", "demo", "--accept-shared-codex"] @?= Just (KitInstall (Just "demo") UserScope (InstallOptions Nothing True))+ parse ["install", "demo", "--shared", "--tool-only"] @?= Nothing,+ testCase "an unknown visibility value is an invalid manifest" $+ withPreparedKitHome $ \_ cache -> do+ BS.writeFile (cache </> "kit.json") (Text.Encoding.encodeUtf8 (Text.replace "\"name\":\"demo\"" "\"visibility\":\"public\",\"name\":\"demo\"" (Text.Encoding.decodeUtf8 manifestJson)))+ assertKitError "invalid manifest" isInvalid =<< loadManifest cache,+ testCase "legacy visibility survives update until explicit reinstall" $+ withPreparedKitHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+ forM_ [ownedDemo home, home </> ".agents/skills/demo"] $ \dir -> do+ meta <- requireSidecar (dir </> ".testkit-kit.json")+ LBS.writeFile (dir </> ".testkit-kit.json") (Aeson.encode (meta & #visibility .~ Nothing & #visibilitySource .~ Nothing & #sharedLinks .~ Nothing & #codexDisabledSkills .~ Nothing))+ declareShared cache+ updateDemo cache+ assertDirectoryMissing (sharedDemo home)+ meta <- demoSidecar home+ meta ^. #visibility @?= Nothing+ legacy <- assertRight =<< checkVisibility testConfig InteractiveCodex UserScope SkillKind "demo"+ legacy ^. #effective @?= SharedVisibility+ _ <- assertRight =<< installItem testConfig "demo" UserScope (InstallOptions (Just ToolOnlyVisibility) False)+ modern <- assertRight =<< checkVisibility testConfig InteractiveCodex UserScope SkillKind "demo"+ modern ^. #requested @?= Just ToolOnlyVisibility+ modern ^. #effective @?= ToolOnlyVisibility,+ testCase "shared multi-file agents link the body and resources" $+ withPreparedKitHome $ \home cache -> do+ plantMultiFileAgent cache+ _ <- assertRight =<< installItem testConfig "reviewer" UserScope sharedOptions+ BS.readFile (home </> ".claude/agents/reviewer/guide.md") >>= (@?= "How to review.\n")+ assertBool "agent link" =<< pathIsSymbolicLink (home </> ".claude/agents/reviewer.md")+ outcomes <- assertRight =<< uninstallItem testConfig "reviewer" UserScope+ length (concatMap (view #linksRemoved) outcomes) @?= 2+ ]+ where+ isNameTaken KitSharedNameTaken {} = True+ isNameTaken _ = False+ isInvalid KitManifestInvalid {} = True+ isInvalid _ = False++sharedOptions :: InstallOptions+sharedOptions = InstallOptions (Just SharedVisibility) False++ownedDemo :: FilePath -> FilePath+ownedDemo home = home </> ".config/testkit/agents/.claude/skills/demo"++sharedDemo :: FilePath -> FilePath+sharedDemo home = home </> ".claude/skills/demo"++requireSidecar :: FilePath -> IO SidecarMeta+requireSidecar path = maybe (assertFailure ("missing sidecar: " <> path)) pure =<< readSidecar path++demoSidecar :: FilePath -> IO SidecarMeta+demoSidecar home = requireSidecar (ownedDemo home </> ".testkit-kit.json")++declareShared :: FilePath -> IO ()+declareShared cache = do+ bytes <- BS.readFile (cache </> "kit.json")+ BS.writeFile (cache </> "kit.json") (Text.Encoding.encodeUtf8 (Text.replace "\"name\":\"demo\"" "\"visibility\":\"shared\",\"name\":\"demo\"" (Text.Encoding.decodeUtf8 bytes)))++updateDemo :: FilePath -> IO ()+updateDemo cache = do+ manifest <- assertRight =<< loadManifest cache+ void (assertRight =<< reinstallPresent testConfig cache manifest (Just "demo") KeepLocalEdits)++codexVisibilityTests :: TestTree+codexVisibilityTests =+ testGroup+ "visibility"+ [ testCase "a tool-only Codex skill adds one disabled config entry" $+ withCodexHome $ \home _ -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ expected <- canonicalizePath (codexDemo home)+ entries <- assertRight =<< readSkillEntries (codexConfig home)+ entries @?= [(expected, False)]+ meta <- requireSidecar (takeDirectory (codexDemo home) </> ".testkit-kit.json")+ meta ^. #codexDisabledSkills @?= Just [Text.pack expected]+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ entries2 <- assertRight =<< readSkillEntries (codexConfig home)+ entries2 @?= entries,+ testCase "the config edit preserves the user's text and permissions" $+ withCodexHome $ \home _ -> do+ forM_ ["", "# no final newline", "# my config\n[projects.\"/x\"]\ntrust_level = \"trusted\"\n\n[[skills.config]]\npath = \"/user/other/SKILL.md\"\nenabled = false\n"] $ \seed -> do+ createDirectoryIfMissing True (home </> ".codex")+ BS.writeFile (codexConfig home) seed+ Posix.setFileMode (codexConfig home) 0o600+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ infoBefore <- Posix.getFileStatus (codexConfig home)+ Posix.fileMode infoBefore .&. 0o777 @?= 0o600+ outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope+ expected <- canonicalizePath (codexDemo home)+ concatMap (view #configEntriesRemoved) outcomes @?= [expected]+ BS.readFile (codexConfig home) >>= (@?= seed),+ testCase "a shared Codex skill writes no entry and switching to shared removes ours" $+ withCodexHome $ \home _ -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+ assertFileMissing (codexConfig home)+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+ entries <- assertRight =<< readSkillEntries (codexConfig home)+ entries @?= [],+ testCase "codexSessionArgs re-enables exactly this tool's hidden skills" $+ withCodexHome $ \home _ -> do+ let root = takeDirectory home </> "project"+ config = testConfig & #projectRoot .~ pure root+ _ <- assertRight =<< installItem config "demo" UserScope defaultInstallOptions+ _ <- assertRight =<< installItem config "demo" ProjectScope defaultInstallOptions+ _ <- assertRight =<< addDisabledSkill (codexConfig home) (home </> "other/SKILL.md")+ user <- canonicalizePath (codexDemo home)+ project <- canonicalizePath (root </> ".agents/skills/demo/SKILL.md")+ args <- codexSessionArgs config+ parseArgs args @?= parseArgs (enableSkillsArgs [user, project])+ codexSessionArgs (config & #providers .~ [InteractiveClaude]) >>= (@?= []),+ testCase "a symlinked Codex config is refused untouched before writes" $+ withCodexHome $ \home _ -> do+ createDirectoryIfMissing True (home </> ".codex")+ BS.writeFile (home </> "generated.toml") "# generated\n"+ createFileLink (home </> "generated.toml") (codexConfig home)+ result <- installItem testConfig "demo" UserScope defaultInstallOptions+ case result of+ Left (KitCodexConfigUnusable _ message) -> assertBool "manual block is in error" ("[[skills.config]]" `Text.isInfixOf` message)+ other -> assertFailure (show other)+ assertDirectoryMissing (ownedDemo home)+ assertFileMissing (codexDemo home)+ getSymbolicLinkTarget (codexConfig home) >>= (@?= home </> "generated.toml")+ BS.readFile (home </> "generated.toml") >>= (@?= "# generated\n"),+ testCase "inline arrays, malformed TOML, and enabled=true are refused" $+ withCodexHome $ \home _ -> do+ createDirectoryIfMissing True (home </> ".codex")+ path <- canonicalizePath (codexDemo home)+ forM_ ["[skills]\nconfig = []\n", "invalid = [", "[[skills.config]]\npath = \"" <> Text.Encoding.encodeUtf8 (Text.pack path) <> "\"\nenabled = true\n"] $ \seed -> do+ BS.writeFile (codexConfig home) seed+ result <- installItem testConfig "demo" UserScope defaultInstallOptions+ assertKitError "unusable config" isConfigError result+ BS.readFile (codexConfig home) >>= (@?= seed)+ assertDirectoryMissing (ownedDemo home),+ testCase "an existing disabled entry is reused and survives uninstall" $+ withCodexHome $ \home _ -> do+ _ <- assertRight =<< addDisabledSkill (codexConfig home) (codexDemo home)+ seed <- BS.readFile (codexConfig home)+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ meta <- requireSidecar (takeDirectory (codexDemo home) </> ".testkit-kit.json")+ meta ^. #codexDisabledSkills @?= Just []+ args <- codexSessionArgs (testConfig & #projectRoot .~ pure (takeDirectory home </> "project"))+ path <- canonicalizePath (codexDemo home)+ parseArgs args @?= parseArgs (enableSkillsArgs [path])+ _ <- assertRight =<< uninstallItem testConfig "demo" UserScope+ BS.readFile (codexConfig home) >>= (@?= seed),+ testCase "update re-adds a deleted config entry including for local edits" $+ withCodexHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+ updateDemo cache+ entries <- assertRight =<< readSkillEntries (codexConfig home)+ assertBool "entry repaired" (length entries == 1)+ _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+ BS.writeFile (ownedDemo home </> "SKILL.md") "local edits\n"+ updateDemo cache+ entries2 <- assertRight =<< readSkillEntries (codexConfig home)+ entries2 @?= entries+ BS.readFile (ownedDemo home </> "SKILL.md") >>= (@?= "local edits\n"),+ testCase "a tool-only agent with Codex is refused without acceptance" $+ withCodexHome $ \home _ -> do+ result <- installItem testConfig "reviewer" UserScope defaultInstallOptions+ assertKitError "cannot isolate agent" (== KitCodexCannotIsolate "reviewer") result+ assertFileMissing (home </> ".config/testkit/agents/.claude/agents/reviewer.md")+ assertFileMissing (home </> ".codex/agents/reviewer.toml"),+ testCase "agent acceptance flag and callbacks work and update never asks" $+ withCodexHome $ \home cache -> do+ let refusing = testConfig & #confirmSharedCodex .~ Just (\_ -> pure False)+ accepting = testConfig & #confirmSharedCodex .~ Just (\_ -> pure True)+ result <- installItem refusing "reviewer" UserScope defaultInstallOptions+ assertKitError "callback refused" (== KitCodexCannotIsolate "reviewer") result+ _ <- assertRight =<< installItem accepting "reviewer" UserScope defaultInstallOptions+ manifest <- assertRight =<< loadManifest cache+ _ <- assertRight =<< reinstallPresent refusing cache manifest (Just "reviewer") KeepLocalEdits+ _ <- assertRight =<< uninstallItem testConfig "reviewer" UserScope+ _ <- assertRight =<< installItem refusing "reviewer" UserScope (InstallOptions Nothing True)+ assertFileExists (home </> ".codex/agents/reviewer.toml")+ _ <- assertRight =<< uninstallItem testConfig "reviewer" UserScope+ _ <- assertRight =<< installItem (refusing & #providers .~ [InteractiveClaude]) "reviewer" UserScope defaultInstallOptions+ assertFileMissing (home </> ".codex/agents/reviewer.toml"),+ testCase "the TOML escaper round-trips quotes, backslashes and controls" $+ withCodexHome $ \home _ -> do+ let path = home </> "quote\"slash\\tab\t/SKILL.md"+ _ <- assertRight =<< addDisabledSkill (codexConfig home) path+ expected <- canonicalizePath path+ entries <- assertRight =<< readSkillEntries (codexConfig home)+ entries @?= [(expected, False)]+ assertBool "launch override parses" (has _Right (parseArgs (enableSkillsArgs [expected]))),+ testCase "a tool-only Codex skill must list SKILL.md" $+ withCodexHome $ \home cache -> do+ BS.writeFile (cache </> "skills/demo/OTHER.md") "other"+ BS.writeFile (cache </> "kit.json") (Text.Encoding.encodeUtf8 (Text.replace "SKILL.md" "OTHER.md" (Text.Encoding.decodeUtf8 manifestJson)))+ assertKitError "no SKILL.md" isConfigError =<< installItem testConfig "demo" UserScope defaultInstallOptions+ assertDirectoryMissing (ownedDemo home)+ ]+ where+ isConfigError KitCodexConfigUnusable {} = True+ isConfigError _ = False++-- Every visibility test uses both isolated roots, as do the older fixtures.+withCodexHome :: (FilePath -> FilePath -> IO a) -> IO a+withCodexHome = withPreparedKitHome++codexConfig :: FilePath -> FilePath+codexConfig home = home </> ".codex/config.toml"++codexDemo :: FilePath -> FilePath+codexDemo home = home </> ".agents/skills/demo/SKILL.md"++parseArgs :: [Text] -> Either String Toml.Table+parseArgs ["-c", override] = Toml.forgetTableAnns <$> Toml.parse override+parseArgs other = Left ("unexpected arguments: " <> show other)++visibilityStatusTests :: TestTree+visibilityStatusTests =+ testGroup+ "visibility"+ [ testCase "status reports requested and effective visibility" $+ withStatusFixture $ \home _ config -> do+ rows <- collectStatus config (fixtureCache home) [(UserScope, "user")]+ let one n p = case filter (\r -> r ^. #name == n && r ^. #providers == p) rows of+ [r] -> pure r+ other -> assertFailure (show other)+ shared <- one "alpha" "claude"+ shared ^. #requestedVisibility @?= Just SharedVisibility+ shared ^. #effectiveVisibility @?= SharedVisibility+ hidden <- one "epsilon" "codex"+ hidden ^. #requestedVisibility @?= Just ToolOnlyVisibility+ hidden ^. #effectiveVisibility @?= ToolOnlyVisibility+ legacy <- one "gamma" "codex"+ legacy ^. #requestedVisibility @?= Nothing+ legacy ^. #effectiveVisibility @?= SharedVisibility+ broken <- one "gamma" "claude"+ broken ^. #requestedVisibility @?= Just SharedVisibility+ broken ^. #effectiveVisibility @?= ToolOnlyVisibility+ assertBool "broken condition" (KitVisibilityBroken `elem` (broken ^. #conditions))+ assertBool "table carries requested mismatch" ("tool-only (requested shared)" `Text.isInfixOf` renderStatusTable rows)+ assertBool "agent mismatch" ("shared (requested tool-only)" `Text.isInfixOf` renderStatusTable rows),+ testCase "a deleted link reports visibility-broken and update clears it" $+ withCodexHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+ removeFile (sharedDemo home)+ before <- assertRight =<< checkVisibility testConfig InteractiveClaude UserScope SkillKind "demo"+ before ^. #broken @?= [sharedDemo home]+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ assertBool "status detects missing link" (any (elem KitVisibilityBroken . view #conditions) rows)+ updateDemo cache+ after <- assertRight =<< checkVisibility testConfig InteractiveClaude UserScope SkillKind "demo"+ after ^. #broken @?= [],+ testCase "a deleted disabled entry reports visibility-broken and update clears it" $+ withCodexHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+ rows <- collectStatus testConfig cache [(UserScope, "user")]+ assertBool "Codex is now shared and broken" (any (\r -> r ^. #providers == "codex" && r ^. #effectiveVisibility == SharedVisibility && KitVisibilityBroken `elem` (r ^. #conditions)) rows)+ updateDemo cache+ after <- assertRight =<< checkVisibility testConfig InteractiveCodex UserScope SkillKind "demo"+ after ^. #effective @?= ToolOnlyVisibility+ after ^. #broken @?= [],+ testCase "status table prints the legacy note only for a shared legacy Codex skill" $+ withStatusFixture $ \home _ config -> do+ rows <- collectStatus config (fixtureCache home) [(UserScope, "user")]+ assertBool "legacy note" ("installed before baikai-kit 0.4.0.0" `Text.isInfixOf` renderStatusTable rows)+ let modern = filter (\r -> has _Just (r ^. #requestedVisibility)) rows+ assertBool "no legacy note for modern copies" (not ("installed before baikai-kit 0.4.0.0" `Text.isInfixOf` renderStatusTable modern)),+ testCase "uninstall preserves foreign Codex assets and a replaced Claude link" $+ withCodexHome $ \home _ -> do+ _ <- assertRight =<< installItem (testConfig & #providers .~ [InteractiveClaude]) "demo" UserScope sharedOptions+ removeFile (sharedDemo home)+ createDirectoryIfMissing True (home </> "foreign")+ createDirectoryLink (home </> "foreign") (sharedDemo home)+ createDirectoryIfMissing True (takeDirectory (codexDemo home))+ BS.writeFile (codexDemo home) "foreign skill\n"+ outcomes <- assertRight =<< uninstallItem testConfig "demo" UserScope+ concatMap (view #linksRemoved) outcomes @?= []+ BS.readFile (codexDemo home) >>= (@?= "foreign skill\n")+ getSymbolicLinkTarget (sharedDemo home) >>= (@?= home </> "foreign")+ ]++visibilityRecoveryTests :: TestTree+visibilityRecoveryTests =+ testGroup+ "visibility"+ [ testCase "shared visibility refuses a user-owned disabled entry before writing assets" $+ withCodexHome $ \home _ -> do+ _ <- assertRight =<< addDisabledSkill (codexConfig home) (codexDemo home)+ seed <- BS.readFile (codexConfig home)+ result <- installItem testConfig "demo" UserScope sharedOptions+ case result of+ Left KitCodexConfigUnusable {} -> pure ()+ other -> assertFailure (show other)+ BS.readFile (codexConfig home) >>= (@?= seed)+ assertDirectoryMissing (ownedDemo home)+ assertFileMissing (codexDemo home),+ testCase "uninstall reports an owned dangling link after its copy and sidecar are lost" $+ withCodexHome $ \home _ -> do+ let config = testConfig & #providers .~ [InteractiveClaude]+ _ <- assertRight =<< installItem config "demo" UserScope sharedOptions+ removeDirectoryRecursive (ownedDemo home)+ outcomes <- assertRight =<< uninstallItem config "demo" UserScope+ concatMap (view #linksRemoved) outcomes @?= [sharedDemo home]+ assertBool "report names removed link" ("1 shared link" `Text.isInfixOf` renderUninstallReport "demo" UserScope outcomes),+ testCase "update does not introduce an unaccepted Codex agent copy" $+ withCodexHome $ \home cache -> do+ _ <- assertRight =<< installItem (testConfig & #providers .~ [InteractiveClaude]) "reviewer" UserScope defaultInstallOptions+ manifest <- assertRight =<< loadManifest cache+ _ <- assertRight =<< reinstallPresent testConfig cache manifest (Just "reviewer") KeepLocalEdits+ assertFileMissing (home </> ".codex/agents/reviewer.toml"),+ testCase "multiple config entries preserve each other when one is removed" $+ withCodexHome $ \home _ -> do+ _ <- assertRight =<< addDisabledSkill (codexConfig home) (codexDemo home)+ _ <- assertRight =<< addDisabledSkill (codexConfig home) (home </> "other/SKILL.md")+ _ <- assertRight =<< removeDisabledSkill (codexConfig home) (codexDemo home)+ expected <- canonicalizePath (home </> "other/SKILL.md")+ entries <- assertRight =<< readSkillEntries (codexConfig home)+ entries @?= [(expected, False)],+ testCase "update finishes pending visibility removals from sidecar ownership" $+ withCodexHome $ \home cache -> do+ _ <- assertRight =<< installItem testConfig "demo" UserScope sharedOptions+ meta <- demoSidecar home+ LBS.writeFile (ownedDemo home </> ".testkit-kit.json") (Aeson.encode (meta & #visibility .~ Just "tool-only"))+ before <- assertRight =<< checkVisibility testConfig InteractiveClaude UserScope SkillKind "demo"+ before ^. #broken @?= [sharedDemo home]+ updateDemo cache+ assertDirectoryMissing (sharedDemo home)+ _ <- assertRight =<< installItem testConfig "demo" UserScope defaultInstallOptions+ let sidecar = takeDirectory (codexDemo home) </> ".testkit-kit.json"+ codexMeta <- requireSidecar sidecar+ LBS.writeFile sidecar (Aeson.encode (codexMeta & #visibility .~ Just "shared" & #visibilitySource .~ Just "install-flag"))+ pending <- assertRight =<< checkVisibility testConfig InteractiveCodex UserScope SkillKind "demo"+ assertBool "pending config cleanup" (not (null (pending ^. #broken)))+ updateDemo cache+ entries <- assertRight =<< readSkillEntries (codexConfig home)+ entries @?= []+ final <- requireSidecar sidecar+ final ^. #codexDisabledSkills @?= Just [],+ testCase "removal tolerates deleted delimiter comments and keeps other TOML" $+ withCodexHome $ \home _ -> do+ path <- canonicalizePath (codexDemo home)+ createDirectoryIfMissing True (home </> ".codex")+ let suffix = "[projects.\"/x\"]\ntrust_level = \"trusted\"\n"+ seed = "# user comment\n[[skills.config]]\npath = \"" <> Text.Encoding.encodeUtf8 (Text.pack path) <> "\"\nenabled = false\n" <> suffix+ BS.writeFile (codexConfig home) seed+ removed <- assertRight =<< removeDisabledSkill (codexConfig home) path+ removed @?= True+ BS.readFile (codexConfig home) >>= (@?= "# user comment\n" <> suffix),+ testCase "a read-only Codex config is refused before writing assets" $+ withCodexHome $ \home _ -> do+ createDirectoryIfMissing True (home </> ".codex")+ BS.writeFile (codexConfig home) "# read only\n"+ Posix.setFileMode (codexConfig home) 0o400+ result <- installItem testConfig "demo" UserScope defaultInstallOptions+ case result of+ Left KitCodexConfigUnusable {} -> pure ()+ other -> assertFailure (show other)+ assertDirectoryMissing (ownedDemo home)+ BS.readFile (codexConfig home) >>= (@?= "# read only\n"),+ testCase "a read-only Codex config parent is refused before writing assets" $+ withCodexHome $ \home _ -> do+ let parent = home </> ".codex"+ createDirectoryIfMissing True parent+ Posix.setFileMode parent 0o500+ flip finally (Posix.setFileMode parent 0o700) $ do+ result <- installItem testConfig "demo" UserScope defaultInstallOptions+ case result of+ Left KitCodexConfigUnusable {} -> pure ()+ other -> assertFailure (show other)+ assertDirectoryMissing (ownedDemo home)+ assertFileMissing (codexConfig home),+ testCase "foreign Codex agent resources are protected without a body file" $+ withCodexHome $ \home _ -> do+ let resource = home </> ".codex/agents/reviewer/data.txt"+ createDirectoryIfMissing True (takeDirectory resource)+ BS.writeFile resource "foreign resource"+ result <- installItem testConfig "reviewer" UserScope sharedOptions+ case result of+ Left KitSharedNameTaken {} -> pure ()+ other -> assertFailure (show other)+ BS.readFile resource >>= (@?= "foreign resource")+ assertFileMissing (home </> ".codex/agents/reviewer.toml")+ assertDirectoryMissing (ownedDemo home),+ testCase "project shared names and foreign Codex agents are protected" $+ withCodexHome $ \home _ -> do+ let root = takeDirectory home </> "project"+ config = testConfig & #projectRoot .~ pure root+ createDirectoryIfMissing True (root </> ".claude/skills/demo")+ result <- installItem config "demo" ProjectScope sharedOptions+ case result of+ Left KitSharedNameTaken {} -> pure ()+ other -> assertFailure (show other)+ assertDirectoryMissing (root </> ".testkit")+ createDirectoryIfMissing True (home </> ".codex/agents")+ BS.writeFile (home </> ".codex/agents/reviewer.toml") "foreign"+ result2 <- installItem testConfig "reviewer" UserScope sharedOptions+ case result2 of+ Left KitSharedNameTaken {} -> pure ()+ other -> assertFailure (show other)+ BS.readFile (home </> ".codex/agents/reviewer.toml") >>= (@?= "foreign"),+ testCase "post-content visibility failure remains tracked and update repairs it" $+ withCodexHome $ \home cache -> do+ let config = testConfig & #providers .~ [InteractiveClaude]+ createDirectoryIfMissing True (home </> ".claude")+ BS.writeFile (home </> ".claude/skills") "blocking parent"+ result <- installItem config "demo" UserScope sharedOptions+ case result of+ Left KitVisibilityNotApplied {} -> pure ()+ other -> assertFailure (show other)+ meta <- demoSidecar home+ meta ^. #sharedLinks @?= Just [Text.pack (sharedDemo home)]+ assertFileExists (ownedDemo home </> "SKILL.md")+ removeFile (home </> ".claude/skills")+ manifest <- assertRight =<< loadManifest cache+ _ <- assertRight =<< reinstallPresent config cache manifest (Just "demo") KeepLocalEdits+ assertBool "repair after blocker clears" =<< pathIsSymbolicLink (sharedDemo home)+ ]
+ test/fixtures/mori-kit.json view
@@ -0,0 +1,34 @@+{+ "version": 2,+ "skills": [+ {+ "name": "automation-config",+ "version": "0.1.0",+ "description": "Author, validate, and debug mori automation configurations",+ "path": "skills/automation-config",+ "files": ["SKILL.md"]+ },+ {+ "name": "mori-config",+ "version": "0.1.0",+ "description": "Author, validate, and edit mori.dhall project configurations",+ "path": "skills/mori-config",+ "files": ["SKILL.md"]+ },+ {+ "name": "cookbook-config",+ "version": "0.1.0",+ "description": "Author, validate, and edit mori/cookbook.dhall cookbook catalogs",+ "path": "skills/cookbook-config",+ "files": ["SKILL.md"]+ },+ {+ "name": "mori-bootstrap-corpus",+ "version": "0.1.0",+ "description": "Bootstrap a complete corpus project from a repo name",+ "path": "skills/mori-bootstrap-corpus",+ "files": ["SKILL.md"]+ }+ ],+ "agents": []+}
+ test/fixtures/rei-kit.json view
@@ -0,0 +1,67 @@+{+ "version": 1,+ "skills": [+ {+ "name": "rei-bootstrap",+ "description": "Interactively bootstrap intentions, habits, and reflections for Rei personal coaching",+ "path": "skills/rei-bootstrap",+ "files": ["SKILL.md"]+ },+ {+ "name": "rei-bootstrap-habit",+ "description": "Interactively bootstrap a new habit for Rei personal coaching",+ "path": "skills/rei-bootstrap-habit",+ "files": ["SKILL.md"]+ },+ {+ "name": "rei-summarize-links",+ "description": "Extract markdown links from a document, summarize each URL, create Rei notes with summaries, and connect them to links via edges",+ "path": "skills/rei-summarize-links",+ "files": ["SKILL.md"]+ },+ {+ "name": "rei-scaffold-kit-skill",+ "description": "Scaffold new Claude Code skills and agents for Rei, following established conventions from the rei codebase",+ "path": "skills/rei-scaffold-kit-skill",+ "files": ["SKILL.md"]+ },+ {+ "name": "rei-ingest-url",+ "description": "Ingest a single URL into Rei — reuse or create a link, summarize the content into a note, connect them with a summarizes edge, and classify the link with author-type, content-type, media, platform, and reused/new topical tags",+ "path": "skills/rei-ingest-url",+ "files": ["SKILL.md"]+ },+ {+ "name": "rei-collection-epub",+ "description": "Export a Rei collection to EPUB format using pandoc, with automatic chapter ordering inferred from titles, edges, content analysis, and timestamps",+ "path": "skills/rei-collection-epub",+ "files": ["SKILL.md"]+ },+ {+ "name": "rei-ingest-url-collection",+ "description": "Read a markdown file of links, run the full rei-ingest-url workflow for each URL, and gather every resulting summary note into a single Rei collection",+ "path": "skills/rei-ingest-url-collection",+ "files": ["SKILL.md"]+ },+ {+ "name": "rei-complete-from-commits",+ "description": "Backlog cleanup — for a given Rei intention and git repository, find commits whose Intention: trailer matches the intention's children and bulk-complete each with --at set to the latest matching commit timestamp; optionally infer dates for children with no trailer matches from pre-trailer commit subjects",+ "path": "skills/rei-complete-from-commits",+ "files": ["SKILL.md"]+ },+ {+ "name": "rei-ingest-markdown",+ "description": "Ingest a pre-converted markdown file (with frontmatter) into Rei — reuse or create a link from the source URL, save the full markdown as an archive note, summarize it into a separate summary note, wire them with `archives` and `summarizes` edges, and classify the link with author-type, content-type, media, platform, and reused/new topical tags",+ "path": "skills/rei-ingest-markdown",+ "files": ["SKILL.md"]+ }+ ],+ "agents": [+ {+ "name": "rei-custom-property-guide",+ "description": "Guide users through creating and managing custom properties in Rei. Helps design property schemas, choose value types, configure category scopes, and create state machine workflows.",+ "path": "agents",+ "files": ["rei-custom-property-guide.md"]+ }+ ]+}
+ test/fixtures/seihou-kit.json view
@@ -0,0 +1,18 @@+{+ "version": 1,+ "skills": [+ {+ "name": "seihou-scaffold-kit-skill",+ "description": "Scaffold new Claude Code skills and agents for Seihou, following established conventions.",+ "path": "skills/seihou-scaffold-kit-skill",+ "files": ["SKILL.md"]+ },+ {+ "name": "seihou-module-readme",+ "description": "Generate or refresh a human-readable README.md for a Seihou module, documenting its version, variables, prompts, dependencies, exports, generated files, and usage.",+ "path": "skills/seihou-module-readme",+ "files": ["SKILL.md"]+ }+ ],+ "agents": []+}
+ test/golden/list.json view
@@ -0,0 +1,1 @@+{"document":"kit-list","formatVersion":1,"items":[{"description":"The alpha skill","installed":[{"path":"$HOME/.config/testkit/agents/.claude/skills/alpha","provider":"claude","scope":"user","version":"0.1.0"},{"path":"$HOME/.agents/skills/alpha","provider":"codex","scope":"user","version":null}],"kind":"skill","name":"alpha","version":"0.1.0","visibility":"shared"},{"description":"The beta skill","installed":[{"path":"$PROJECT/.testkit/agents/.claude/skills/beta","provider":"claude","scope":"project","version":"0.1.0"},{"path":"$PROJECT/.agents/skills/beta","provider":"codex","scope":"project","version":"0.1.0"}],"kind":"skill","name":"beta","version":"0.2.0","visibility":"tool-only"},{"description":"The gamma skill","installed":[{"path":"$HOME/.config/testkit/agents/.claude/skills/gamma","provider":"claude","scope":"user","version":"0.1.0"},{"path":"$HOME/.agents/skills/gamma","provider":"codex","scope":"user","version":"0.1.0"}],"kind":"skill","name":"gamma","version":"0.1.0","visibility":"shared"},{"description":"The epsilon skill","installed":[{"path":"$HOME/.config/testkit/agents/.claude/skills/epsilon","provider":"claude","scope":"user","version":"0.1.0"},{"path":"$HOME/.agents/skills/epsilon","provider":"codex","scope":"user","version":"0.1.0"}],"kind":"skill","name":"epsilon","version":"0.1.0","visibility":"tool-only"},{"description":"The reviewer agent","installed":[{"path":"$HOME/.config/testkit/agents/.claude/agents/reviewer.md","provider":"claude","scope":"user","version":"0.1.0"},{"path":"$HOME/.codex/agents/reviewer.toml","provider":"codex","scope":"user","version":"0.1.0"}],"kind":"agent","name":"reviewer","version":"0.1.0","visibility":"tool-only"},{"description":"The planner agent","installed":[{"path":"$PROJECT/.testkit/agents/.claude/agents/planner.md","provider":"claude","scope":"project","version":"0.1.0"},{"path":"$PROJECT/.codex/agents/planner.toml","provider":"codex","scope":"project","version":"0.1.0"}],"kind":"agent","name":"planner","version":"0.1.0","visibility":"tool-only"}],"upstream":{"detail":"<detail>","state":"stale"}}
+ test/golden/status.json view
@@ -0,0 +1,1 @@+{"document":"kit-status","formatVersion":1,"items":[{"conditions":[],"effectiveVisibility":"shared","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"alpha","provider":"claude","requestedVisibility":"shared","scope":"user","upToDate":true},{"conditions":["unknown"],"effectiveVisibility":"shared","installedVersion":null,"kind":"skill","latestVersion":"0.1.0","name":"alpha","provider":"codex","requestedVisibility":null,"scope":"user","upToDate":false},{"conditions":["outdated"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.2.0","name":"beta","provider":"claude","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":["outdated"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.2.0","name":"beta","provider":"codex","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":["delisted"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":null,"name":"delta","provider":"claude","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":["delisted"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":null,"name":"delta","provider":"codex","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":["refused"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"epsilon","provider":"claude","requestedVisibility":"tool-only","scope":"user","upToDate":false},{"conditions":["refused"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"epsilon","provider":"codex","requestedVisibility":"tool-only","scope":"user","upToDate":false},{"conditions":["changed-upstream","visibility-broken"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"gamma","provider":"claude","requestedVisibility":"shared","scope":"user","upToDate":false},{"conditions":["changed-upstream"],"effectiveVisibility":"shared","installedVersion":"0.1.0","kind":"skill","latestVersion":"0.1.0","name":"gamma","provider":"codex","requestedVisibility":null,"scope":"user","upToDate":false},{"conditions":["edits-unknown"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"agent","latestVersion":"0.1.0","name":"planner","provider":"claude","requestedVisibility":"tool-only","scope":"project","upToDate":false},{"conditions":[],"effectiveVisibility":"shared","installedVersion":"0.1.0","kind":"agent","latestVersion":"0.1.0","name":"planner","provider":"codex","requestedVisibility":"tool-only","scope":"project","upToDate":true},{"conditions":["modified"],"effectiveVisibility":"tool-only","installedVersion":"0.1.0","kind":"agent","latestVersion":"0.1.0","name":"reviewer","provider":"claude","requestedVisibility":"tool-only","scope":"user","upToDate":false},{"conditions":[],"effectiveVisibility":"shared","installedVersion":"0.1.0","kind":"agent","latestVersion":"0.1.0","name":"reviewer","provider":"codex","requestedVisibility":"tool-only","scope":"user","upToDate":true}],"upstream":{"detail":"<detail>","state":"stale"}}
+ test/golden/update.json view
@@ -0,0 +1,1 @@+{"document":"kit-update","formatVersion":1,"refresh":null,"skipped":[{"name":"demo","reason":"locally-modified","scope":"user"}],"updated":[{"name":"reviewer","scope":"user"}]}