diff --git a/CHANGELOG.md b/CHANGELOG.md index de87d62..bcd3222 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,38 @@ ## Unreleased +## 0.1.0 — not yet published (tag `v0.1.0`; see docs/publishing.md) + +Kit writes: loadouts apply to a project or to your home with a plan, a confirmation and a +byte-for-byte undo. + +- User scope: `--user` (the same as `--repo ~`) on plan/apply/use/off/status/eject writes + `~/.claude/settings.json`, `~/.claude/CLAUDE.md`, `~/.codex/config.toml`, + `~/.codex/hooks.json`, `~/.codex/AGENTS.md` and user skill folders, honouring + `CLAUDE_CONFIG_DIR` and `CODEX_HOME` within home. Intent and ledger live in `~/.kit/`. + Claude user MCP servers are a gap with the exact `claude mcp add --scope user` command + (owner decision: Kit does not write `~/.claude.json`); Cursor user rules are a gap. + Codex single-skill disables become owned `[[skills.config]]` tables (TOML arrays of + tables are now owned one table at a time, like JSON `/-` entries). +- Skill directories: a warehouse skill with a directory and a `SKILL.md` is copied into + `.claude/skills/`, `.agents/skills/` (and `.cursor/skills/`), one owned whole file per + asset; updates follow the warehouse and `off` removes them. Symlinks, non-UTF-8 assets + and files Kit did not write are conflicts. Fix: an owned whole file (a skill file or a + Cursor rule file) whose content changes is now updated instead of reported unmanaged. +- Setup steps: `kitctl use` asks to run the loadout's `setup` steps after writing + (`--yes` runs them), skipping a step whose `when` file is missing or whose + `${plugin:NAME}` is not installed (resolved from Claude Code's installed_plugins.json). + Outputs stay the tool's. A failed step makes `use` exit 1. +- `kitctl init [PATH]` creates a starter warehouse (default `KIT_WAREHOUSE` or `~/kits`): + `loadouts/starter.yaml` (an instruction block and a `tool:git` check), empty + `primitives//` folders, a README, and `git init` unless `--no-git`. It refuses a + non-empty directory. +- Writes for everyone (owner decision, 2026-10-21): `use`, `off`, `apply` and `eject` write + wherever they are pointed after showing the plan and asking `[y/N]` on a terminal + (`--yes` skips it; with no terminal and no `--yes`, nothing is written). The undo is + `kitctl off`. A directory that is not a git repository, uncommitted tracked changes and a + default branch are warnings, not refusals. `$KITCTL_SANDBOX`, when set, still confines + every write. `--live` and `live: true` from the opt-in below are accepted and ignored. - Live apply, owner-authorized: outside `$KITCTL_SANDBOX`, `use`, `off`, `apply` and `eject` write only with `--live`, a committed `kit.yaml` that says `live: true`, no uncommitted tracked changes and a named branch other than @@ -9,7 +41,7 @@ uncommitted files only when its journal names every one. Each unmet condition is named; git runs without inherited `GIT_*` variables. `kit.yaml` accepts the `live` key and keeps CRLF line endings. A no-op apply writes nothing and needs - no permission. See docs/apply.md#live-apply. + no permission. Superseded by the entry above; see docs/apply.md#writing. - Cursor: project skills go to `.cursor/skills//` and doctor sees them there; every Cursor hook carries a `trust-required` gap (trusted workspaces load project hooks); a loadout that writes only the `.mdc` rule still gets the unverified trust gap. Source: cursor/cookbook@6733ef8. - Cursor is a third harness (`--harness cursor`, `harnesses: [cursor]` in kit.yaml). diff --git a/PLAN.md b/PLAN.md index ca63aea..a9070ab 100644 --- a/PLAN.md +++ b/PLAN.md @@ -45,13 +45,13 @@ requirements; it is not an independent grant of permission to change live config |---|---|---| | 0 Preflight | Tool/auth checks; missing requirements recorded | Complete | | 1 Names | Registry/API checks plus binary collision research | Complete; requested name retained | -| 2 Bootstrap/release | Repos, useful 0.0.1, sandbox tests, Linux/Windows CI; npm and PyPI uploads verified | Complete; npm, TestPyPI, and PyPI 0.0.1 verified; all four CI jobs green | +| 2 Bootstrap/release | Repos, useful 0.0.1, sandbox tests, Linux/Windows CI; npm and PyPI uploads verified | Complete; npm, TestPyPI, and PyPI 0.0.1 verified; all four CI jobs green. 0.1.0 (writes, init, skills, setup, user scope) prepared 2026-10-21; publication is the owner's (docs/publishing.md). | | 3 Design/model | Review docs/design.md; schemas and validation; no generator until reviewed | M1 (2026-10-19): schema v2 model, `validate`, read-only `resolve` with capability gaps, brand-studio golden output for both harnesses. Model approved and merged 2026-10-19. | | 4 Inventory | Explicitly authorized real roots, skill/config/MCP/plugin discovery, usage adapters, matrix, drift report | M2 (2026-10-19): inventory reads Claude/Codex plugins, marketplaces, MCP (with redacted args), hooks, installed plugin shas and managed blocks over explicit roots; `doctor` reports tools, MCP launchability, unmanaged slot conflicts, stale and ineffective disables, pin drift, pending trust and hook heartbeats. Usage adapters and the drift matrix are still pending. | | 5 Triage | Proposed classifications/diffs; confirm choices; archive retirement; commit warehouse | Pending inventory review | -| 6 Core/apply | Sandbox policy tests, preview global diff, confirm apply, verify trust and effectiveness | M3 (2026-10-19): ledger with loadout/profile attribution, journaled project apply, `plan/apply/use/off/status/eject`, generators for brand-studio's targets; sandbox acceptance green. Live apply (2026-10-21): outside `$KITCTL_SANDBOX`, writes need `--live`, a committed `live: true` in kit.yaml, a clean tracked tree and a non-default branch. User-scope generators and setup execution pending. | +| 6 Core/apply | Sandbox policy tests, preview global diff, confirm apply, verify trust and effectiveness | M3 (2026-10-19): ledger with loadout/profile attribution, journaled project apply, `plan/apply/use/off/status/eject`, generators for brand-studio's targets; sandbox acceptance green. Writing (2026-10-21, owner decision): writes go anywhere after the plan and a confirmation (`--yes` skips it); git state warns and never refuses; `$KITCTL_SANDBOX`, when set, confines writes. Skill directories, setup steps (after a confirmation) and user scope (`--user`, 2026-10-21) are generated; Claude user MCP stays a remedy by owner decision. | | 7 Capture | Guidance, write guards, incremental reconcile, loop prevention, measured <300ms warm path | Pending | -| 8 Pilot | Select low-risk repo; preview/apply, harness smoke tests, migration runbook | M6 (2026-10-19): owner named the pilot; real dry run only (20 planned additions, 15 gaps, nothing written); runbook in docs/pilot-brand-studio.md with two findings to resolve before any apply. Live apply authorized 2026-10-21 as a per-repo opt-in; not yet run on the pilot. | +| 8 Pilot | Select low-risk repo; preview/apply, harness smoke tests, migration runbook | M6 (2026-10-19): owner named the pilot; real dry run only (20 planned additions, 15 gaps, nothing written); runbook in docs/pilot-brand-studio.md with two findings to resolve before any apply. Writes authorized 2026-10-21; not yet run on the pilot. | | Forge (new) | scout/vendor, then forge new/propose/verify and the kit-forge skill | M4 (2026-10-19): `scout` (quarantine, facts with file:line evidence, never executes) and `vendor` (pinned path into inbox/ with provenance, hash, notices, license policy). M5 (2026-10-19): `forge new/propose/verify` and the `kit-forge` skill (examples/); end-to-end forge over three fixture repos; brand-builder's suite runs through `forge verify`. Offering kit-forge and brand-studio to agent-kits awaits the owner. | | 9 Removal | Purge restores baseline; eject preserves effective config with zero Kit dependencies | Sandbox proofs (M3): `off` restores the baseline byte for byte; eject leaves native files with no Kit markers, ledger or state. | diff --git a/README.md b/README.md index dce318b..49ac01d 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,37 @@ # Kit (`kitctl`) -Kit inventories the skills, MCP configuration, hooks, and plugins used by coding -agents (Claude Code, Codex and, documented but not yet verified, Cursor). The planned compiler manages named **loadouts** from a separate private -warehouse and writes each harness's native configuration with an ownership ledger. -The planned default warehouse location is `~/kits`; setup will ask which parent -directories contain your repositories. - -**0.0.1 is an early, read-only release.** It discovers skill folders and known -configuration files in explicitly selected roots, computes SHA-256 hashes, and -prints text or JSON. It does not yet parse connectors, count usage, compile -loadouts, reconcile changes, or modify agent configuration. +Kit manages the skills, MCP servers, hooks, plugins and instruction blocks your coding +agents (Claude Code, Codex and, documented but not yet verified, Cursor) load. You keep +named **loadouts** in a warehouse (`~/kits` by default); kitctl writes each harness's own +config for them in a project or in your home, with an ownership ledger so `kitctl off` +puts back exactly what was there. + +**0.1.0** can: +- start a warehouse (`kitctl init`), check it (`validate`) and show what a loadout means + per harness, with every capability gap and its remedy (`resolve`); +- check a loadout against what is actually installed (`doctor`, read-only); +- apply loadouts to a project or to your home (`--user`): `use`, `off`, `apply`, + `status`, `eject`, each showing its plan and asking before it writes; +- copy warehouse skill folders and run a loadout's setup steps (after asking). + +Kit never writes trust: approving hooks, MCP servers and projects stays with you. ## Run Python 3.11+: ```sh -# From a checkout (works before the PyPI release): -uv run kitctl --version -uv run kitctl inventory --root /path/to/repo --json +uv tool install kitctl # or: uvx kitctl --version +kitctl init && kitctl validate +kitctl use starter --repo path/to/repo -# After publication to PyPI: -uvx kitctl --version -uv tool install kitctl +# From a checkout: +uv run kitctl --version ``` Node 20+ with [uv installed](https://docs.astral.sh/uv/getting-started/installation/): ```sh -# After publication to npm: npx kitctl --version npx kitctl inventory --root /path/to/repo --json ``` @@ -37,6 +40,14 @@ The npm package is a thin launcher around the bundled Python wheel from the same release. It uses `uvx` with that local wheel; it does not require a PyPI release, run install scripts, or ship a `kit` binary. uv manages Python if needed. +Start a warehouse of your own (`~/kits`, or `KIT_WAREHOUSE`), with a `starter` loadout +that only adds an instruction block: + +```sh +uv run kitctl init +uv run kitctl validate +``` + Loadouts (read-only; nothing is written): ```sh @@ -54,14 +65,16 @@ uv run kitctl doctor --loadout brand-studio --root ~ --root path/to/repo ``` `use`, `off`, `plan`, `apply`, `status` and `eject` manage a project's native -config through an ownership ledger. They write inside `$KITCTL_SANDBOX`, or in a -real repository only with `--live` and that repository's committed opt-in (see -[apply](docs/apply.md#live-apply)): +config through an ownership ledger. They show the plan and ask before writing +(`--yes` skips the question), and `kitctl off` restores the original bytes (see +[apply](docs/apply.md#writing)): ```sh -export KITCTL_SANDBOX=/tmp/kit-sandbox -uv run kitctl use brand-studio --repo /tmp/kit-sandbox/site --dry-run -uv run kitctl use brand-studio --repo path/to/repo --live --yes # after the opt-in +uv run kitctl use brand-studio --repo path/to/repo --dry-run # the plan only +uv run kitctl use brand-studio --repo path/to/repo # the plan, then [y/N] +uv run kitctl off brand-studio --repo path/to/repo # the undo +uv run kitctl use core --user # ~/.claude, ~/.codex, ~/.agents +KITCTL_SANDBOX=/tmp/kit-sandbox uv run kitctl ... # confine writes to one directory ``` `resolve` prints the effective native config a loadout would produce and every diff --git a/docs/apply.md b/docs/apply.md index a445fb5..0bdbb7a 100644 --- a/docs/apply.md +++ b/docs/apply.md @@ -1,14 +1,14 @@ # Plan, apply and removal (M3) -Status 2026-10-21: proven in sandbox tests. Live apply is authorized by the -owner as an explicit opt-in per repository (see [Live apply](#live-apply)). -Every other write refuses unless the project is inside `$KITCTL_SANDBOX`. +Status 2026-10-21: writes go wherever you point them, after Kit shows the plan and +you confirm (see [Writing](#writing)). `$KITCTL_SANDBOX`, when set, confines every write +to one directory. ## Files in a project | File | Role | Committed? | |---|---|---| -| `kit.yaml` | Intent: `loadouts` (`name` or `name:profile`, one profile per loadout), `harnesses` (`claude`, `codex`, `cursor`; default `[claude, codex]`) and the optional `live: true` opt-in | yes | +| `kit.yaml` | Intent: `loadouts` (`name` or `name:profile`, one profile per loadout), `harnesses` (`claude`, `codex`, `cursor`; default `[claude, codex]`) (a `live: true` from the earlier opt-in is read and ignored) | yes | | `kit.lock` | Ledger: every owned entry with its owners (loadout and profile), the parents and files Kit created, and the warehouse commit. A checksum detects hand edits. | yes | | `.kit/` | Local recovery state: `lock`, `journal.json`, and `preimages/` (original bytes of files Kit edits). It contains its own `.gitignore` (`*`). | no | @@ -28,47 +28,75 @@ kitctl status # loadouts, owned entries (ok/drift/missi kitctl eject --yes # keep native files, drop kit.yaml, kit.lock, .kit/ and markers ``` -## Live apply +## Writing -A write lands in a real repository only when every one of these holds, and -kitctl names each one that does not: +`use`, `off`, `apply` and `eject` print the plan, then ask `Write N files? [y/N]` on a +terminal. `--yes` skips the question (scripts, CI); without a terminal and without +`--yes`, nothing is written. `--dry-run` shows the plan and never asks. -1. `--live` is on the command line (`use`, `off`, `apply`, `eject`). -2. The **committed** `kit.yaml` says `live: true`. The opt-in is read from - `git show HEAD:./kit.yaml`, so editing the working copy is not enough: the - owner commits the decision. -3. No tracked file has uncommitted changes, so each apply is its own reviewable - commit and none of the owner's work is mixed into it. Untracked files do not - count. Files git does not track (`.mcp.json` when ignored, say) are restored - by `kitctl off` from `.kit/preimages`, not by git. The one exception is - recovering an interrupted apply, and only when every uncommitted file is - one its journal names. -4. HEAD is a named branch other than `main`, `master` and the remote's - default (`origin/HEAD`). +The undo is `kitctl off `: it removes only that loadout's entries and restores +the original bytes from `.kit/preimages`, with or without git. Before writing, Kit warns +(and never refuses) when: -The opt-in records the owner's decision; it is not a lock. Anyone who can -commit to the repository, an agent with a shell included, can commit -`live: true`. Review that commit like any other. +- the directory is not a git repository (`off` is then the only undo); +- tracked files have uncommitted changes (Kit's edits will mix with them); +- HEAD is on `main`, `master` or the remote's default branch (a branch lets the change be + reviewed before it lands). -`plan`, `status` and `--dry-run` read only and never need the opt-in. A -sandbox is unchanged: inside `$KITCTL_SANDBOX` no opt-in is needed. Never point -`KITCTL_SANDBOX` at a real repository to get past these checks. +When `$KITCTL_SANDBOX` is set, every write must land inside it, so a test run or a first +try cannot touch anything else. `--live` and `live: true` from the earlier opt-in are accepted and do +nothing. ```sh -git switch -c kit/first-apply -kitctl use gestalt-flow --dry-run # review the diff -# add `live: true` to kit.yaml (create it with `schema_version: 1` if absent) -git add kit.yaml && git commit -m "kit: opt in to live apply" -kitctl use gestalt-flow --live --yes +git switch -c kit/first-apply # optional; Kit warns on main +kitctl use gestalt-flow --dry-run # the plan, nothing written +kitctl use gestalt-flow # the plan, then [y/N] kitctl status # every owned entry should read ok -git add -A && git commit -m "kit: gestalt-flow" # the next live write needs a clean tree -kitctl off gestalt-flow --live --yes # the undo; restores the original bytes +kitctl off gestalt-flow # the undo; restores the original bytes ``` -Live apply still writes project files only. User-scope (`~/`) targets, setup -steps and skill directories are not generated; they stay gaps with remedies. - -Every write takes `--dry-run`. Without `--yes`, nothing is written. +## User scope + +`--user` (the same as `--repo ~`) applies a loadout to your home instead of a project: +every session on the machine sees it, so Kit warns before writing. Intent and ledger live +in `~/.kit/kit.yaml` and `~/.kit/kit.lock`; `kitctl off --user` restores the +original bytes like it does in a project. + +| Project target | User target | +|---|---| +| `.claude/settings.json`, `.claude/skills//` | `~/.claude/…` (or `$CLAUDE_CONFIG_DIR/…`) | +| `CLAUDE.md` block | `~/.claude/CLAUDE.md` | +| `.codex/config.toml`, `.codex/hooks.json` | `~/.codex/…` (or `$CODEX_HOME/…`) | +| `AGENTS.md` block | `~/.codex/AGENTS.md` | +| `.agents/skills//`, `.cursor/mcp.json`, `.cursor/hooks.json` | `~/.agents/…`, `~/.cursor/…` | +| `.mcp.json` (Claude MCP) | a gap whose remedy is the `claude mcp add --scope user …` command: user servers live in `~/.claude.json`, which Claude Code rewrites all the time, so Kit does not own it | +| `.cursor/rules/kit-.mdc`, `.cursor/skills/` | gaps: Cursor keeps user rules in its settings | + +Codex reads single-skill disables only from user config, so in user scope a disabled skill +becomes one `[[skills.config]]` table (`name`, `enabled = false`) Kit owns by value; your +own tables stay. `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME` move the targets, but only within +home; one pointing elsewhere is a conflict. Setup steps are listed, not run: they belong to +projects. Project trust gaps do not apply. + +Kit does not write through symlinks, so a `~/.claude` or `~/.codex` linked from a dotfiles +repository (stow and the like) is refused; point `CLAUDE_CONFIG_DIR` / `CODEX_HOME` at the +real folder under home instead. Kit removes only its own files from `~/.kit/`, and a +comment inside a `[[skills.config]]` table Kit owns blocks `off` until you move it. + +**Skills.** A skill primitive with a directory (`primitives/skills//` with a +`SKILL.md`) is copied to `.claude/skills//` and `.agents/skills//` +(`.cursor/skills/` for Cursor), one owned file per asset. A changed skill in the warehouse +updates the copy; `off` removes it. An existing file Kit did not write, a symlink, or a +non-UTF-8 asset is a conflict. External skills (`external: true`) are not copied. + +**Setup steps.** After `kitctl use` writes a loadout's config, it lists the loadout's +`setup` steps and asks `Run N setup step(s)? [y/N]` (`--yes` runs them). A step whose +`when: file-exists:PATH` is missing is skipped with the reason, and `${plugin:NAME}` becomes +the installed Claude Code plugin's directory (skipped when the plugin is not installed). +Steps run in the project with no shell; their outputs belong to the tool, so `off` leaves +them. `kitctl use ` again re-runs them; `apply` only lists them. + +Every write takes `--dry-run`. Without a terminal and without `--yes`, nothing is written. ## Generators @@ -116,6 +144,5 @@ Cursor effect also comes with an `unverified` gap. - **Formatting.** JSON keeps indentation, key order, line endings and the final newline, but `json.dumps` expands compact arrays in files Kit edits. CRLF files stay CRLF. -- **Not done yet.** `setup` steps are listed in the plan but not run. Skill - directory copies are not generated. User-scope (`~/`) targets are not - generated; brand-studio is entirely project-scoped. +- **Not done yet.** User-scope (`~/`) targets are not generated; brand-studio is + entirely project-scoped. diff --git a/docs/harness-reference.md b/docs/harness-reference.md index e8ca6b2..725e29b 100644 --- a/docs/harness-reference.md +++ b/docs/harness-reference.md @@ -215,7 +215,8 @@ throwaway config. These facts supersede the "documented" entries above. "installed, enabled 0.3.1". No separate `codex plugin add` is needed. - **verified**: `[[skills.config]] name = "…" enabled = false` in a trusted project's config is **ignored**: `codex debug prompt-input` still lists the skill. - The same entry in user config removes it. Single-skill disables need user scope. + The same entry in user config removes it. Single-skill disables need user scope, which + `kitctl use --user` writes as owned `[[skills.config]]` tables. - **verified**: `[mcp_servers.]` in a trusted project's config is listed and enabled by `codex mcp list`. @@ -255,7 +256,7 @@ so anything attributed to them below is recalled, not re-read. | `plugin_hooks` | plugin `hook_provides` slots count | documented | Plugins ship `hooks/hooks.json`, with commands using `${CURSOR_PLUGIN_ROOT}`. | | `plugin_enable`, `plugin_disable` | `manual-step` / `unsupported` gaps | documented (absence) | Plugins are installed from the Cursor marketplace. No project file that enables or disables one is documented, and the clone has only plugin-side manifests. | | `marketplace_file`, `marketplace_sha_pin` | `manual-step` gap | documented (absence) | A marketplace is `.cursor-plugin/marketplace.json` inside its own repo (`schemas/marketplace.schema.json`). No project file registers one, and no commit pin is documented. | -| `skill_dir` | `.cursor/skills//` (Kit does not copy skill directories on any harness yet; the engine reports it) | documented | [cursor/cookbook](https://github.com/cursor/cookbook) at `6733ef8`, `hooks/README.md`: `update-skills-on-stop.mjs` maintains `.cursor/skills/*/SKILL.md`. | +| `skill_dir` | `.cursor/skills//` (Kit copies a warehouse skill directory there, one owned file per asset) | documented | [cursor/cookbook](https://github.com/cursor/cookbook) at `6733ef8`, `hooks/README.md`: `update-skills-on-stop.mjs` maintains `.cursor/skills/*/SKILL.md`. | | `skill_disable` | `unsupported` gap | documented (absence) | No per-skill switch is documented. | | `hooks_trust_required` | `trust-required` gap on every Cursor hook | documented | cursor/cookbook `6733ef8` `hooks/README.md`: "Trusted workspaces automatically load project hooks from `.cursor/hooks.json`." | | `project_trust_required` | `unverified` gap, including a loadout that writes only the rule file | unknown | No source says whether Cursor gates the other `.cursor/` files on workspace trust. Kit claims no trust step, flags that as unverified, and the first sandbox probe has to settle it. | diff --git a/docs/owner-checklist.md b/docs/owner-checklist.md index 6922a2c..5043708 100644 --- a/docs/owner-checklist.md +++ b/docs/owner-checklist.md @@ -60,8 +60,8 @@ that ships inside a plugin. base-path feature) - [ ] **Managed block placement.** Put it in the root `AGENTS.md`/`CLAUDE.md` (applies repo-wide), or in `apps/marketing/AGENTS.md`/`CLAUDE.md`? -- [x] **Authorize a live apply.** Authorized 2026-10-21, as a per-repository - opt-in ([apply.md](apply.md#live-apply)). +- [x] **Authorize a live apply.** Authorized 2026-10-21. Since 0.1.0 a write needs + only a confirmation ([apply.md](apply.md#writing)). ## 5. After a real apply (only you can do these; Kit never writes trust) diff --git a/docs/pilot-brand-studio.md b/docs/pilot-brand-studio.md index 65ca534..6dd721f 100644 --- a/docs/pilot-brand-studio.md +++ b/docs/pilot-brand-studio.md @@ -1,9 +1,8 @@ # Pilot runbook: brand-studio Status, 2026-10-21: only a **dry run** has been done, and it wrote nothing. -The owner has authorized live apply as a per-repository opt-in -([apply.md](apply.md#live-apply)); without it, kitctl refuses to write anywhere -outside `$KITCTL_SANDBOX`. +Writes now go wherever they are pointed after a confirmation +([apply.md](apply.md#writing)). The steps that need the owner are in [owner-checklist.md](owner-checklist.md). @@ -28,7 +27,7 @@ gaps. | Codex | `AGENTS.md` (existing) | appended managed block | Not written: the brand-builder edit hook ships with the plugin on both -harnesses. The `voice-compile` setup step is listed in the plan but not run. +harnesses. The `voice-compile` setup step runs after `kitctl use` when you confirm it. ## Expected gaps @@ -88,19 +87,17 @@ enabled for the user. Without that root, disables are reported as heartbeat check reports "fired at …" once the hook has been trusted and a site file has been edited. -## Apply and remove (live opt-in) +## Apply and remove -Outside a sandbox these commands need `--live` and the pilot's own opt-in -([apply.md](apply.md#live-apply)). Do not point `KITCTL_SANDBOX` at a real -repository to get past it. Commit after each write, because the next one needs -a clean tree. +Each command shows the plan and asks first (`--yes` skips it). Apply on a branch, +following the repo's own rules; Kit warns on main. ```sh -kitctl use brand-studio --repo --live --yes # journaled, ledger in kit.lock +kitctl use brand-studio --repo --yes # journaled, ledger in kit.lock kitctl status --repo # each owned entry should be ok -kitctl use brand-studio:audit --repo --live --yes # writes only the delta -kitctl off brand-studio --repo --live --yes # restores the original bytes -kitctl eject --repo --live --yes # or: keep native files, drop Kit +kitctl use brand-studio:audit --repo --yes # writes only the delta +kitctl off brand-studio --repo --yes # restores the original bytes +kitctl eject --repo --yes # or: keep native files, drop Kit ``` `off` removes only brand-studio's entries. If those entries were edited diff --git a/docs/publishing.md b/docs/publishing.md index 8c7de18..f75d4fd 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,3 +1,20 @@ +# Publishing + +## 0.1.0 — prepared 2026-10-21, not yet published + +The owner publishes; nothing is uploaded from a cloud session. Same procedure as 0.0.1 +below, with the new tag: + +1. Merge the release PR to main and confirm CI is green on Linux and Windows. +2. Tag the merge commit: `git tag v0.1.0 && git push origin v0.1.0`. +3. `gh workflow run publish-python.yml --ref v0.1.0 -f target=testpypi`, then + `uvx --index https://test.pypi.org/simple/ --from kitctl==0.1.0 kitctl --version`. +4. `gh workflow run publish-python.yml --ref v0.1.0 -f target=pypi`, then + `uvx --from kitctl==0.1.0 kitctl --version`. +5. npm, from the same tag: the [npm](#npm) steps with `kitctl-0.1.0.tgz`. +6. Smoke test in a temporary HOME: `kitctl init`, `kitctl validate`, + `kitctl use starter --repo --yes`, `kitctl off starter --repo --yes`. + # Publishing 0.0.1 ## Verified release record — 2026-10-11 diff --git a/npm/README.md b/npm/README.md index 617e13b..49ac01d 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1,34 +1,37 @@ # Kit (`kitctl`) -Kit inventories the skills, MCP configuration, hooks, and plugins used by coding -agents. The planned compiler manages named **loadouts** from a separate private -warehouse and writes each harness's native configuration with an ownership ledger. -The planned default warehouse location is `~/kits`; setup will ask which parent -directories contain your repositories. - -**0.0.1 is an early, read-only release.** It discovers skill folders and known -configuration files in explicitly selected roots, computes SHA-256 hashes, and -prints text or JSON. It does not yet parse connectors, count usage, compile -loadouts, reconcile changes, or modify agent configuration. +Kit manages the skills, MCP servers, hooks, plugins and instruction blocks your coding +agents (Claude Code, Codex and, documented but not yet verified, Cursor) load. You keep +named **loadouts** in a warehouse (`~/kits` by default); kitctl writes each harness's own +config for them in a project or in your home, with an ownership ledger so `kitctl off` +puts back exactly what was there. + +**0.1.0** can: +- start a warehouse (`kitctl init`), check it (`validate`) and show what a loadout means + per harness, with every capability gap and its remedy (`resolve`); +- check a loadout against what is actually installed (`doctor`, read-only); +- apply loadouts to a project or to your home (`--user`): `use`, `off`, `apply`, + `status`, `eject`, each showing its plan and asking before it writes; +- copy warehouse skill folders and run a loadout's setup steps (after asking). + +Kit never writes trust: approving hooks, MCP servers and projects stays with you. ## Run Python 3.11+: ```sh -# From a checkout (works before the PyPI release): -uv run kitctl --version -uv run kitctl inventory --root /path/to/repo --json +uv tool install kitctl # or: uvx kitctl --version +kitctl init && kitctl validate +kitctl use starter --repo path/to/repo -# After publication to PyPI: -uvx kitctl --version -uv tool install kitctl +# From a checkout: +uv run kitctl --version ``` Node 20+ with [uv installed](https://docs.astral.sh/uv/getting-started/installation/): ```sh -# After publication to npm: npx kitctl --version npx kitctl inventory --root /path/to/repo --json ``` @@ -37,12 +40,21 @@ The npm package is a thin launcher around the bundled Python wheel from the same release. It uses `uvx` with that local wheel; it does not require a PyPI release, run install scripts, or ship a `kit` binary. uv manages Python if needed. +Start a warehouse of your own (`~/kits`, or `KIT_WAREHOUSE`), with a `starter` loadout +that only adds an instruction block: + +```sh +uv run kitctl init +uv run kitctl validate +``` + Loadouts (read-only; nothing is written): ```sh uv run kitctl validate ~/kits uv run kitctl resolve brand-studio --harness claude --warehouse ~/kits uv run kitctl resolve brand-studio:audit --harness codex --json +uv run kitctl resolve brand-studio --harness cursor # documented facts only; see the gaps ``` `doctor` checks a loadout against what is actually installed in the roots you @@ -53,12 +65,16 @@ uv run kitctl doctor --loadout brand-studio --root ~ --root path/to/repo ``` `use`, `off`, `plan`, `apply`, `status` and `eject` manage a project's native -config through an ownership ledger. Until live apply is authorized, they write only -inside `$KITCTL_SANDBOX` (see [apply](docs/apply.md)): +config through an ownership ledger. They show the plan and ask before writing +(`--yes` skips the question), and `kitctl off` restores the original bytes (see +[apply](docs/apply.md#writing)): ```sh -export KITCTL_SANDBOX=/tmp/kit-sandbox -uv run kitctl use brand-studio --repo /tmp/kit-sandbox/site --dry-run +uv run kitctl use brand-studio --repo path/to/repo --dry-run # the plan only +uv run kitctl use brand-studio --repo path/to/repo # the plan, then [y/N] +uv run kitctl off brand-studio --repo path/to/repo # the undo +uv run kitctl use core --user # ~/.claude, ~/.codex, ~/.agents +KITCTL_SANDBOX=/tmp/kit-sandbox uv run kitctl ... # confine writes to one directory ``` `resolve` prints the effective native config a loadout would produce and every diff --git a/npm/bin/kitctl.mjs b/npm/bin/kitctl.mjs index 3e84e3f..7d4b8c9 100755 --- a/npm/bin/kitctl.mjs +++ b/npm/bin/kitctl.mjs @@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; -const wheel = fileURLToPath(new URL("../python/kitctl-0.0.1-py3-none-any.whl", import.meta.url)); +const wheel = fileURLToPath(new URL("../python/kitctl-0.1.0-py3-none-any.whl", import.meta.url)); if (!existsSync(wheel)) { console.error("kitctl: bundled Python wheel is missing; reinstall the npm package."); process.exit(1); diff --git a/npm/package.json b/npm/package.json index 098f79c..f339b6d 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,6 +1,6 @@ { "name": "kitctl", - "version": "0.0.1", + "version": "0.1.0", "description": "Kit: inventory and manage agent skills, MCP connectors, hooks, and loadouts", "type": "module", "bin": { "kitctl": "bin/kitctl.mjs" }, diff --git a/npm/test/cli.test.mjs b/npm/test/cli.test.mjs index 0204202..6444e52 100644 --- a/npm/test/cli.test.mjs +++ b/npm/test/cli.test.mjs @@ -14,7 +14,7 @@ test("bundled wheel works without a PyPI release and forwards arguments", () => try { const version = spawnSync(process.execPath, [bin, "--version"], { env, encoding: "utf8" }); assert.equal(version.status, 0, version.stderr); - assert.match(version.stdout, /^kitctl 0\.0\.1\r?\n$/); + assert.match(version.stdout, /^kitctl 0\.1\.0\r?\n$/); const repo = path.join(root, "repo with spaces"); mkdirSync(repo); writeFileSync(path.join(repo, "AGENTS.md"), "Fixture instructions"); diff --git a/pyproject.toml b/pyproject.toml index a4cd7d5..e756919 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "kitctl" -version = "0.0.1" +version = "0.1.0" description = "Inventory and manage agent skills, MCP connectors, hooks, and loadouts." readme = "README.md" requires-python = ">=3.11" diff --git a/src/kitctl/__init__.py b/src/kitctl/__init__.py index 1dbf0a4..af6123c 100644 --- a/src/kitctl/__init__.py +++ b/src/kitctl/__init__.py @@ -1,3 +1,3 @@ """Kit: inventory and manage agent tooling through deterministic loadouts.""" -__version__ = "0.0.1" +__version__ = "0.1.0" diff --git a/src/kitctl/cli.py b/src/kitctl/cli.py index 5defad3..6aa20fa 100644 --- a/src/kitctl/cli.py +++ b/src/kitctl/cli.py @@ -4,11 +4,13 @@ import argparse import json +import shlex +import subprocess import sys from pathlib import Path from typing import Any -from kitctl import __version__, engine +from kitctl import __version__, engine, starter from kitctl import forge as forging from kitctl import scout as scouting from kitctl.doctor import doctor @@ -34,6 +36,11 @@ def main(argv: list[str] | None = None) -> int: help="Repository or home directory to inspect; repeatable. Defaults to current directory.", ) scan.add_argument("--json", action="store_true", help="Print metadata and hashes as JSON.") + start = commands.add_parser("init", help="Create a starter warehouse (default: ~/kits).") + start.add_argument( + "path", nargs="?", type=Path, help="Where to create it (default: KIT_WAREHOUSE or ~/kits)." + ) + start.add_argument("--no-git", action="store_true", help="Do not run git init.") check = commands.add_parser("validate", help="Check warehouse documents and loadouts.") check.add_argument( "path", nargs="?", type=Path, help="Warehouse root (default: KIT_WAREHOUSE or ~/kits)." @@ -71,17 +78,18 @@ def main(argv: list[str] | None = None) -> int: def project(sub: argparse.ArgumentParser, writes: bool) -> None: sub.add_argument("--repo", type=Path, default=None, help="Project root (default: cwd).") + sub.add_argument( + "--user", + action="store_true", + help="User scope: ~/.claude, ~/.codex and ~/.agents instead of a project.", + ) sub.add_argument("--warehouse", type=Path, help="Warehouse root (default: ~/kits).") sub.add_argument("--json", action="store_true", help="Print JSON.") if writes: sub.add_argument("--yes", action="store_true", help="Write without asking.") sub.add_argument("--dry-run", action="store_true", help="Show the plan only.") - sub.add_argument( - "--live", - action="store_true", - help="Write to a real repo (outside KITCTL_SANDBOX). Needs a committed kit.yaml " - "with `live: true`, a clean tree and a non-default branch.", - ) + # The earlier opt-in needed --live; accepted so scripts that pass it still run. + sub.add_argument("--live", action="store_true", help=argparse.SUPPRESS) project( commands.add_parser("plan", help="Show the diff kit.yaml would produce; reads only."), False @@ -149,6 +157,8 @@ def project(sub: argparse.ArgumentParser, writes: bool) -> None: return _project_command(args) if args.command == "doctor": return _doctor(args) + if args.command == "init": + return _init(args) if args.command == "validate": return _validate(args) if args.command == "resolve": @@ -282,14 +292,75 @@ def _print_plan(plan: engine.Plan, as_json: bool) -> None: for gap in plan.gaps: print(f"gap [{gap['severity']}] {gap['harness']} {gap['subject']}: {gap['reason']}") for step in plan.setup: - print(f"setup (not run; outputs are not Kit-owned): {' '.join(step['run'])}") + print(f"setup (`kitctl use` runs it; outputs are not Kit-owned): {' '.join(step['run'])}") for conflict in plan.conflicts: print(f"conflict: {conflict}", file=sys.stderr) +def _init(args: argparse.Namespace) -> int: + root = warehouse_root(args.path) + try: + made = starter.init_warehouse(root, git=not args.no_git) + except starter.InitError as error: + print(f"kitctl: {error}", file=sys.stderr) + return 1 + print(f"created a warehouse at {root}:") + for rel in made: + print(f" {rel}") + default = warehouse_root(None) + hint = "" if root == default else f" (set KIT_WAREHOUSE={root} or pass --warehouse {root})" + print(f"next: kitctl validate {root}, then `kitctl use starter` in a repo{hint}") + return 0 + + +def _run_setup(args: argparse.Namespace, repo: Path, steps: list[dict[str, Any]]) -> int: + """Run a loadout's setup steps after its config is written, asking first like a write.""" + runs = engine.setup_runs(repo, steps) + for run in runs: + if run["skip"]: + print(f"setup skipped ({run['skip']}): {shlex.join(map(str, run['step']['run']))}") + ready = [run for run in runs if run["argv"]] + if not ready or not _confirm(args, f"Run {len(ready)} setup step(s)?"): + if ready: + print("setup not run; `kitctl use` again (or --yes) runs it", file=sys.stderr) + return 0 + failed = 0 + for run in ready: + print(f"$ {shlex.join(run['argv'])}", flush=True) + try: + code = subprocess.run(run["argv"], cwd=repo, check=False).returncode + except OSError as error: + print(f"kitctl: setup could not start: {error}", file=sys.stderr) + code = 127 + if code: + failed += 1 + print(f"kitctl: setup step exited {code}", file=sys.stderr) + return 1 if failed else 0 + + +def _confirm(args: argparse.Namespace, question: str) -> bool: + """--yes, or a y at an interactive prompt (on stderr, so --json output stays clean).""" + if args.yes: + return True + if args.dry_run or not sys.stdin.isatty(): + return False + print(f"{question} [y/N] ", end="", file=sys.stderr, flush=True) + try: + return input().strip().lower() in {"y", "yes"} + except EOFError: + return False + + def _project_command(args: argparse.Namespace) -> int: - repo = (args.repo or Path.cwd()).resolve() + if args.user and args.repo is not None: + print("kitctl: --user and --repo name different roots; pass one", file=sys.stderr) + return 2 + repo = (Path.home() if args.user else (args.repo or Path.cwd())).resolve() warehouse = warehouse_root(args.warehouse) + if getattr(args, "live", False): + print( + "kitctl: --live is no longer needed; writes ask first (--yes skips it)", file=sys.stderr + ) try: if args.command == "status": report = engine.status(repo, warehouse) @@ -313,21 +384,26 @@ def _project_command(args: argparse.Namespace) -> int: drift = any(e["state"] != "ok" for e in report["entries"]) return 1 if drift or report["interrupted"] else 0 if args.command == "eject": - if not args.yes or args.dry_run: - print( - "eject keeps native files and removes kit.yaml, kit.lock, .kit/ and block " - "markers; re-run with --yes" - ) - return 0 if args.dry_run else 1 - for target in engine.eject(repo, args.live): + print( + "eject keeps native files and removes kit.yaml, kit.lock, .kit/ and block markers" + ) + if args.dry_run: + return 0 + engine.check_write_allowed(repo) + if not _confirm(args, "Eject?"): + print("Nothing written; re-run with --yes to eject.", file=sys.stderr) + return 1 + for target in engine.eject(repo): print(f"ejected {target}") return 0 recovered = False if args.command != "plan" and (repo / engine.JOURNAL).exists(): - if args.dry_run or not args.yes: - print("an apply was interrupted; re-run with --yes to recover it first") + print("an apply was interrupted") + engine.check_write_allowed(repo) + if not _confirm(args, "Recover it first?"): + print("Nothing written; re-run with --yes to recover it first.", file=sys.stderr) return 1 - recovered = engine.recover(repo, args.live) + recovered = engine.recover(repo) intent = engine.read_intent(repo) if args.command == "use": intent = engine.with_loadout(intent, args.target) @@ -335,15 +411,25 @@ def _project_command(args: argparse.Namespace) -> int: intent = engine.without_loadout(intent, args.target) plan = engine.make_plan(repo, warehouse, intent) _print_plan(plan, args.json) + if args.command != "plan" and (plan.changes or plan.remove_dirs) and not plan.conflicts: + for warning in engine.write_warnings(repo): + print(f"warning: {warning}", file=sys.stderr) if args.command == "plan" or args.dry_run: return 1 if plan.conflicts else 0 - if not args.yes: - print("Nothing written; re-run with --yes to apply.", file=sys.stderr) - return 1 - written = engine.apply_plan(plan, args.live) + if plan.changes or plan.remove_dirs: + engine.check_write_allowed(repo) + if not plan.conflicts and not _confirm(args, f"Write {len(plan.changes)} files?"): + print("Nothing written; re-run with --yes to apply.", file=sys.stderr) + return 1 + written = engine.apply_plan(plan) if recovered: print("recovered an interrupted apply") print(f"wrote {len(written)} files" if written else "already up to date") + if args.command == "use" and plan.setup: + if engine.is_user_scope(repo): + print("setup steps run in projects; `kitctl use` in a repo runs them") + return 0 + return _run_setup(args, repo, plan.setup) return 0 except engine.EngineError as error: print(f"kitctl: {error}", file=sys.stderr) diff --git a/src/kitctl/engine.py b/src/kitctl/engine.py index 6d93f8c..9a29fec 100644 --- a/src/kitctl/engine.py +++ b/src/kitctl/engine.py @@ -39,6 +39,24 @@ LOCKFILE = f"{STATE}/lock" PREIMAGES = f"{STATE}/preimages" LEDGER_VERSION = 1 +# User scope (the root is home): intent and ledger live in .kit/, never loose in ~. +USER_INTENT = f"{STATE}/kit.yaml" +USER_LEDGER = f"{STATE}/kit.lock" + + +def is_user_scope(repo: Path) -> bool: + """Kit runs in user scope when the root is the home directory (`--user`).""" + return repo.resolve() == Path.home().resolve() + + +def intent_path(repo: Path) -> str: + return USER_INTENT if is_user_scope(repo) else INTENT + + +def ledger_path(repo: Path) -> str: + return USER_LEDGER if is_user_scope(repo) else LEDGER + + SANDBOX_ENV = "KITCTL_SANDBOX" JSON_TARGETS = { ".claude/settings.json", @@ -105,74 +123,45 @@ def _git(repo: Path, *args: str, strip: bool = True) -> str | None: return done.stdout.strip() if strip else done.stdout -def live_refusals(repo: Path, recovering: bool = False) -> list[str]: - """Why a live (non-sandbox) write is refused here; empty when every owner opt-in holds. - - Live apply is the owner's explicit choice, made in the repository itself: the committed - kit.yaml says `live: true`, the tracked tree is clean (so git can undo the apply), and the - work happens on a named branch: never main, master or the remote's default. The one - exception to a clean tree is recovery of an interrupted apply, and only when every - uncommitted file is one its journal names (Kit's own writes in flight). - """ +def write_warnings(repo: Path) -> list[str]: + """What to know before Kit writes here. None of it blocks: `kitctl off` restores the + original bytes from Kit's own preimages in .kit/, with or without git.""" + if is_user_scope(repo): + return ["user scope: every session on this machine sees these changes"] if _git(repo, "rev-parse", "--is-inside-work-tree") != "true": - return ["not a git work tree; live apply needs git as its undo"] - reasons = [] - committed = _git(repo, "show", f"HEAD:./{INTENT}") - try: - data = YAML(typ="safe", pure=True).load(committed) if committed else None - except Exception: # noqa: BLE001 - any unreadable committed kit.yaml means no opt-in - data = None - if not (isinstance(data, dict) and data.get("live") is True): - reasons.append(f"the committed {INTENT} does not say `live: true`") - status = _git(repo, "status", "--porcelain", "-z", "--untracked-files=no", strip=False) - if status is None or (status and not (recovering and _only_in_flight(repo, status))): - reasons.append("tracked files have uncommitted changes; commit or stash them first") + return ["not a git repository: `kitctl off` is the undo (Kit keeps the original bytes)"] + warnings = [] + if _git(repo, "status", "--porcelain", "--untracked-files=no"): + warnings.append("tracked files have uncommitted changes; Kit's edits will mix with them") branch = _git(repo, "symbolic-ref", "--quiet", "--short", "HEAD") - if not branch: - reasons.append("HEAD is detached; check out a named branch") - else: - remote_head = _git(repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") - protected = {"main", "master"} - if remote_head and "/" in remote_head: - protected.add(remote_head.split("/", 1)[1]) - if branch in protected: - reasons.append(f"{branch} is a default branch; apply on another branch") - return reasons - + remote_head = _git(repo, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD") + protected = {"main", "master"} + if remote_head and "/" in remote_head: + protected.add(remote_head.split("/", 1)[1]) + if branch in protected: + warnings.append(f"on {branch}; a branch lets the change be reviewed before it lands") + return warnings -def _only_in_flight(repo: Path, status: str) -> bool: - """Every path in `git status -z` output is a target of the interrupted apply's journal.""" - try: - journal = json.loads((repo / JOURNAL).read_text(encoding="utf-8")) - targets = {item["target"] for item in journal["files"]} - except (OSError, ValueError, KeyError, TypeError): - return False - prefix = _git(repo, "rev-parse", "--show-prefix") or "" - paths = [entry[3:] for entry in status.split("\0") if len(entry) > 3] - return all(p.startswith(prefix) and p[len(prefix) :] in targets for p in paths) +def check_write_allowed(repo: Path) -> None: + """When $KITCTL_SANDBOX is set, every write stays inside it (tests, CI, a first try). -def check_write_allowed(repo: Path, live: bool = False, recovering: bool = False) -> None: - """Writes land inside $KITCTL_SANDBOX, or in a repo whose owner opted in to live apply.""" + Kit's own state is checked too: a symlinked .kit/ or .kit/preimages would carry the + preimages, journal and lock somewhere else, sandbox or not. + """ + for rel in (STATE, PREIMAGES): + if (repo / rel).is_symlink(): + raise EngineError(f"{rel} is a symlink; Kit keeps its state in the project only") sandbox = os.environ.get(SANDBOX_ENV) - if sandbox: - try: - repo.resolve().relative_to(Path(sandbox).resolve()) - return - except ValueError: - if not live: - raise EngineError( - f"{repo} is outside {SANDBOX_ENV}={sandbox}; refusing to write " - "(a real repository needs --live and its owner's opt-in; see docs/apply.md)" - ) from None - if not live: + if not sandbox: + return + try: + repo.resolve().relative_to(Path(sandbox).resolve()) + except ValueError: raise EngineError( - f"writes need a sandbox ({SANDBOX_ENV}) or a live apply: pass --live in a repo " - f"whose committed {INTENT} says `live: true` (see docs/apply.md)" - ) - reasons = live_refusals(repo, recovering) - if reasons: - raise EngineError("live apply refused: " + "; ".join(reasons)) + f"{repo} is outside {SANDBOX_ENV}={sandbox}; refusing to write " + f"(unset {SANDBOX_ENV} to write here)" + ) from None def _yaml() -> YAML: @@ -182,10 +171,11 @@ def _yaml() -> YAML: def read_intent(repo: Path) -> dict[str, Any]: - path = repo / INTENT + path = repo / intent_path(repo) if not path.exists(): return {"schema_version": 1, "loadouts": [], "harnesses": list(DEFAULT_HARNESSES)} data = YAML(typ="safe", pure=True).load(path.read_text(encoding="utf-8")) or {} + # `live` was the earlier opt-in to write outside the sandbox; still read, no longer needed. keys = {"schema_version", "loadouts", "harnesses", "live"} if not isinstance(data, dict) or set(data) - keys: raise EngineError(f"{path}: expected schema_version, loadouts, harnesses and live only") @@ -207,7 +197,7 @@ def read_intent(repo: Path) -> dict[str, Any]: def render_intent(repo: Path, intent: dict[str, Any]) -> bytes | None: """kit.yaml bytes for an intent, keeping comments of an existing file.""" - path = repo / INTENT + path = repo / intent_path(repo) if not intent["loadouts"] and not path.exists(): return None yaml = _yaml() @@ -249,7 +239,7 @@ def empty_ledger() -> dict[str, Any]: def read_ledger(repo: Path) -> dict[str, Any]: - path = repo / LEDGER + path = repo / ledger_path(repo) if not path.exists(): return empty_ledger() try: @@ -291,14 +281,82 @@ class Desired: conflicts: list[str] = field(default_factory=list) -def desired_state(warehouse: Path, intent: dict[str, Any]) -> Desired: +def _expand_directories( + warehouse: Path, effects: list[dict[str, Any]], conflicts: list[str] +) -> list[dict[str, Any]]: + """A skill directory becomes one whole file per asset, each owned by path like a generated + rule file, so `off` removes exactly what Kit copied and drift shows per file.""" + out = [] + for effect in effects: + if effect["format"] != "directory": + out.append(effect) + continue + kind, _, name = effect["source"].partition(":") + root = warehouse / "primitives" / f"{kind}s" / PurePosixPath(name) + if not root.is_dir() or not (root / "SKILL.md").is_file(): + conflicts.append( + f"{effect['source']}: no skill directory with a SKILL.md in the warehouse" + ) + continue + for path in sorted(p for p in root.rglob("*") if p.is_file() or p.is_symlink()): + rel = path.relative_to(root).as_posix() + if path.is_symlink(): + conflicts.append(f"{effect['source']}: {rel} is a symlink; Kit copies files only") + continue + try: + # Owned whole files are LF, like the content hash; a CRLF checkout of the + # warehouse must not read as drift on every apply. + text = path.read_bytes().decode("utf-8").replace("\r\n", "\n") + except UnicodeDecodeError: + conflicts.append(f"{effect['source']}: {rel} is not UTF-8 text; copy it by hand") + continue + out.append( + { + **effect, + "target": f"{effect['target']}{rel}", + "format": "file", + "key": effect["source"], + "value": text, + } + ) + return out + + +def _user_targets() -> tuple[dict[str, str], dict[str, str]]: + """Prefix map for user scope: .claude/ and .codex/ move with CLAUDE_CONFIG_DIR and + CODEX_HOME, but only to a folder under home other than Kit's own .kit/. A bad value is + a problem only for the effects that would use it (prefix -> problem).""" + home = Path.home().resolve() + prefixes: dict[str, str] = {} + problems: dict[str, str] = {} + for prefix, env in ((".claude/", "CLAUDE_CONFIG_DIR"), (".codex/", "CODEX_HOME")): + value = os.environ.get(env) + if not value: + continue + try: + rel = Path(value).expanduser().resolve().relative_to(home).as_posix() + except ValueError: + problems[prefix] = f"{env}={value} is outside home; user scope writes only under home" + continue + if rel == "." or rel == STATE or rel.startswith(f"{STATE}/"): + problems[prefix] = f"{env}={value} must name a folder under home, not home or {STATE}/" + continue + prefixes[prefix] = f"{rel}/" + return prefixes, problems + + +def desired_state(warehouse: Path, intent: dict[str, Any], user: bool = False) -> Desired: result = Desired() + prefixes: dict[str, str] = {} + problems: dict[str, str] = {} + if user: + prefixes, problems = _user_targets() for target in intent["loadouts"]: name, profile = split_target(target) owner = {"loadout": name, "profile": profile} for harness in intent["harnesses"]: try: - plan = resolve(warehouse, target, harness) + plan = resolve(warehouse, target, harness, scope="user" if user else "project") except ModelError as error: raise EngineError(f"{target} ({harness}): {error}") from error effects = list(plan["effects"]) @@ -312,12 +370,14 @@ def desired_state(warehouse: Path, intent: dict[str, Any]) -> Desired: "value": block["text"], } ) - for effect in effects: - if effect["format"] == "directory": - result.conflicts.append( - f"{effect['source']}: copying skill directories is not generated yet" - ) + for effect in _expand_directories(warehouse, effects, result.conflicts): + bad = [p for prefix, p in problems.items() if effect["target"].startswith(prefix)] + if bad: + result.conflicts.extend(c for c in bad if c not in result.conflicts) continue + for old, new in prefixes.items(): + if effect["target"].startswith(old): + effect = {**effect, "target": new + effect["target"][len(old) :]} if "${plugin:" in _canonical(effect["value"]): result.conflicts.append( f"{effect.get('source', effect['target'])} ({harness}): needs the " @@ -352,6 +412,53 @@ def desired_state(warehouse: Path, intent: dict[str, Any]) -> Desired: return result +# Setup ------------------------------------------------------------------------------------------- + +PLUGIN_REF = re.compile(r"\$\{plugin:([A-Za-z0-9._-]+)\}") + + +def _installed_plugin_paths(home: Path) -> dict[str, str]: + """Plugin name -> install path, from Claude Code's installed_plugins.json.""" + from kitctl.native import read_native + + paths: dict[str, str] = {} + for item in read_native(home.expanduser().resolve(), home)["installed_plugins"]: + name = str(item.get("id", "")).split("@", 1)[0] + if item.get("path") and name not in paths: + paths[name] = str(item["path"]) + return paths + + +def setup_runs( + repo: Path, steps: list[dict[str, Any]], home: Path | None = None +) -> list[dict[str, Any]]: + """Each setup step ready to run (`argv`), or why it is skipped (`skip`). Reads only. + + A step's `when: file-exists:PATH` is checked in the project, and `${plugin:NAME}` becomes + the installed plugin's directory. Setup outputs are the tool's, never Kit-owned. + """ + installed: dict[str, str] | None = None + runs: list[dict[str, Any]] = [] + for step in steps: + when = str(step.get("when") or "") + if when.startswith("file-exists:") and not (repo / PurePosixPath(when[12:])).exists(): + runs.append({"step": step, "argv": None, "skip": f"{when[12:]} not found"}) + continue + argv: list[str] = [] + skip: str | None = None + for part in step["run"]: + for name in PLUGIN_REF.findall(str(part)): + if installed is None: + installed = _installed_plugin_paths(home or Path.home()) + if name not in installed: + skip = f"plugin {name} is not installed (Claude Code); install it, then re-run" + break + part = str(part).replace(f"${{plugin:{name}}}", installed[name]) + argv.append(str(part)) + runs.append({"step": step, "argv": None if skip else argv, "skip": skip}) + return runs + + # Files ------------------------------------------------------------------------------------------- @@ -369,6 +476,7 @@ def __init__(self, repo: Path, target: str, fmt: str) -> None: self.fmt = fmt self.path = repo / PurePosixPath(target) self.conflict: str | None = None + self.cleared = False if self.path.is_symlink() or any( p.is_symlink() for p in self.path.parents if p.is_relative_to(repo) and p != repo ): @@ -406,9 +514,12 @@ def _get(self, key: str, value: Any) -> Any: return value if current is not MISSING and value in current else MISSING return current if self.fmt == "toml": - return formats.toml_get(self.doc, key) + return formats.toml_get(self.doc, key, value) if self.fmt == "file": - return MISSING if self.before is None else self.text.replace("\r\n", "\n") + # A whole file Kit just took back reads as absent, so an update re-inserts it. + if self.before is None or self.cleared: + return MISSING + return self.text.replace("\r\n", "\n") return formats.block_get(self.text, key) def insert(self, key: str, value: Any) -> tuple[list[str], dict[str, Any]]: @@ -419,7 +530,8 @@ def insert(self, key: str, value: Any) -> tuple[list[str], dict[str, Any]]: if self.fmt == "toml": return formats.toml_set(self.doc, key, value), {} if self.fmt == "file": - self.text = value # only reached when the file is absent: Kit never overwrites one + self.text = value # only when absent or just taken back: Kit never overwrites one + self.cleared = False return [], {} self.text, separator = formats.block_insert(self.text, key, value) return [], {"separator": separator} @@ -434,9 +546,10 @@ def remove(self, entry: dict[str, Any], created: list[str]) -> None: if self.fmt == "json": formats.json_delete(self.doc, entry["key"], entry["value"], created) elif self.fmt == "toml": - formats.toml_delete(self.doc, entry["key"], created) + formats.toml_delete(self.doc, entry["key"], created, entry["value"]) elif self.fmt == "file": self.text = "" # empty: deleted below because Kit created it + self.cleared = True else: self.text = formats.block_remove(self.text, entry["key"], entry.get("separator", "")) @@ -519,7 +632,7 @@ def make_plan(repo: Path, warehouse: Path, intent: dict[str, Any]) -> Plan: if (repo / JOURNAL).exists(): raise EngineError("an earlier apply was interrupted; run `kitctl apply` to recover first") ledger = read_ledger(repo) - desired = desired_state(warehouse, intent) + desired = desired_state(warehouse, intent, is_user_scope(repo)) conflicts = list(desired.conflicts) owned = {entry_id(e): e for e in ledger["entries"]} docs: dict[str, Doc] = {} @@ -662,16 +775,17 @@ def doc(target: str, fmt: str) -> Doc: "created_files": sorted(created_files), "created_parents": created_parents, "created_dirs": sorted(created_dirs), - "kit_yaml_created": ledger.get("kit_yaml_created", not (repo / INTENT).exists()), + "kit_yaml_created": ledger.get("kit_yaml_created", not (repo / intent_path(repo)).exists()), } - lock_before = (repo / LEDGER).read_bytes() if (repo / LEDGER).exists() else None - intent_before = (repo / INTENT).read_bytes() if (repo / INTENT).exists() else None + ledger_rel, intent_rel = ledger_path(repo), intent_path(repo) + lock_before = (repo / ledger_rel).read_bytes() if (repo / ledger_rel).exists() else None + intent_before = (repo / intent_rel).read_bytes() if (repo / intent_rel).exists() else None if intent["loadouts"]: - files[LEDGER] = (lock_before, render_ledger(body, commit)) - files[INTENT] = (intent_before, render_intent(repo, intent)) + files[ledger_rel] = (lock_before, render_ledger(body, commit)) + files[intent_rel] = (intent_before, render_intent(repo, intent)) else: - files[LEDGER] = (lock_before, None) - files[INTENT] = ( + files[ledger_rel] = (lock_before, None) + files[intent_rel] = ( intent_before, None if body["kit_yaml_created"] else render_intent(repo, intent), ) @@ -775,6 +889,25 @@ def _commit_files(repo: Path, journal: dict[str, Any]) -> None: os.replace(staged, path) +def _clear_state(repo: Path) -> None: + """Drop Kit's state. A project's .kit/ is Kit's alone; ~/.kit may hold other tools' + files, so in user scope only Kit's own files go, and the folder only if it is empty.""" + state = repo / STATE + if not is_user_scope(repo): + shutil.rmtree(state, ignore_errors=True) + return + for rel in (JOURNAL, LOCKFILE, USER_INTENT, USER_LEDGER): + with contextlib.suppress(FileNotFoundError): + (repo / rel).unlink() + shutil.rmtree(repo / PREIMAGES, ignore_errors=True) + ignore = state / ".gitignore" + with contextlib.suppress(OSError): + if ignore.read_text(encoding="utf-8") == "*\n": + ignore.unlink() + with contextlib.suppress(OSError): + state.rmdir() + + def _finish(repo: Path, journal: dict[str, Any]) -> None: for directory in journal["remove_dirs"]: with contextlib.suppress(OSError): @@ -784,15 +917,15 @@ def _finish(repo: Path, journal: dict[str, Any]) -> None: _preimage_path(repo, target).unlink() (repo / JOURNAL).unlink() if journal["clear_state"]: - shutil.rmtree(repo / STATE, ignore_errors=True) + _clear_state(repo) -def recover(repo: Path, live: bool = False) -> bool: +def recover(repo: Path) -> bool: """Roll an interrupted apply forward. Returns True if there was something to recover.""" path = repo / JOURNAL if not path.exists(): return False - check_write_allowed(repo, live, recovering=True) + check_write_allowed(repo) journal = json.loads(path.read_text(encoding="utf-8")) with project_lock(repo): _commit_files(repo, journal) @@ -800,12 +933,12 @@ def recover(repo: Path, live: bool = False) -> bool: return True -def apply_plan(plan: Plan, live: bool = False) -> list[str]: +def apply_plan(plan: Plan) -> list[str]: """Write a plan. Returns the targets written. Raises without writing on conflicts.""" repo = plan.repo if not plan.changes and not plan.remove_dirs and not plan.conflicts: return [] # nothing to write needs no permission (as after a recovery) - check_write_allowed(repo, live) + check_write_allowed(repo) if plan.conflicts: raise ConflictError(plan.conflicts) changes = plan.changes @@ -856,7 +989,9 @@ def apply_plan(plan: Plan, live: bool = False) -> list[str]: "files": files, "remove_dirs": plan.remove_dirs, "drop_preimages": [t for t in changes if t not in still], - "clear_state": not plan.intent["loadouts"], + # In user scope the intent lives in .kit/; keep the folder if Kit did not create it. + "clear_state": not plan.intent["loadouts"] + and not (is_user_scope(repo) and not plan.ledger.get("kit_yaml_created", True)), } _write_atomic(repo / JOURNAL, json.dumps(journal, indent=2).encode()) _commit_files(repo, journal) @@ -919,7 +1054,9 @@ def status(repo: Path, warehouse: Path) -> dict[str, Any]: for target in intent["loadouts"]: for harness in intent["harnesses"]: try: - plan = resolve(warehouse, target, harness) + plan = resolve( + warehouse, target, harness, scope="user" if is_user_scope(repo) else "project" + ) except ModelError as error: report.setdefault("errors", []).append(f"{target} ({harness}): {error}") continue @@ -933,9 +1070,9 @@ def status(repo: Path, warehouse: Path) -> dict[str, Any]: return report -def eject(repo: Path, live: bool = False) -> list[str]: +def eject(repo: Path) -> list[str]: """Keep native config as plain files; drop Kit's markers, ledger, intent and state.""" - check_write_allowed(repo, live) + check_write_allowed(repo) if (repo / JOURNAL).exists(): raise EngineError("an earlier apply was interrupted; run `kitctl apply` to recover first") ledger = read_ledger(repo) @@ -961,9 +1098,9 @@ def eject(repo: Path, live: bool = False) -> list[str]: text = formats.strip_markers(text, name) _write_atomic(path, text.encode("utf-8")) touched.append(target) - for name in (LEDGER, INTENT): + for name in (ledger_path(repo), intent_path(repo)): if (repo / name).exists(): (repo / name).unlink() touched.append(name) - shutil.rmtree(repo / STATE, ignore_errors=True) + _clear_state(repo) return touched diff --git a/src/kitctl/formats.py b/src/kitctl/formats.py index 96a774c..0341bec 100644 --- a/src/kitctl/formats.py +++ b/src/kitctl/formats.py @@ -16,7 +16,8 @@ from typing import Any import tomlkit -from tomlkit.items import Comment, Table +from tomlkit.exceptions import TOMLKitError +from tomlkit.items import AoT, Comment, Table MISSING: Any = object() @@ -164,7 +165,31 @@ def _plain(value: Any) -> Any: return value.unwrap() if hasattr(value, "unwrap") else value -def toml_get(doc: tomlkit.TOMLDocument, key: str) -> Any: +def _tomlkit_errors(function: Any) -> Any: + """tomlkit cannot edit some valid TOML (an array of tables split around other tables); + say what to change instead of crashing.""" + + def wrapped(*args: Any, **kwargs: Any) -> Any: + try: + return function(*args, **kwargs) + except TOMLKitError as error: + raise FormatError( + f"cannot edit this TOML safely ({error}); keep each [[array]] of tables in " + "one run, then re-run" + ) from error + + return wrapped + + +@_tomlkit_errors +def toml_get(doc: tomlkit.TOMLDocument, key: str, value: Any = None) -> Any: + """A key's value. `a.b/-` names one table in the array of tables `[[a.b]]`: the one + equal to `value`, like a JSON `/-` pointer.""" + if key.endswith("/-"): + tables = toml_get(doc, key[:-2]) + if not isinstance(tables, list): + return MISSING + return value if any(t == value for t in tables) else MISSING node: Any = doc for part in toml_key(key): if not hasattr(node, "get") or part not in node: @@ -180,13 +205,15 @@ def _table(value: dict[str, Any]) -> Table: return table +@_tomlkit_errors def toml_set(doc: tomlkit.TOMLDocument, key: str, value: Any) -> list[str]: - parts = toml_key(key) + append = key.endswith("/-") + parts = toml_key(key[:-2] if append else key) created: list[str] = [] node: Any = doc for depth, part in enumerate(parts[:-1]): if part not in node: - holds_scalar = depth == len(parts) - 2 and not isinstance(value, dict) + holds_scalar = depth == len(parts) - 2 and not isinstance(value, dict) and not append # Tables that only hold tables stay implicit, so no empty [header] lines appear. node.add(part, tomlkit.table(is_super_table=not holds_scalar)) created.append(toml_join(parts[: depth + 1])) @@ -194,6 +221,16 @@ def toml_set(doc: tomlkit.TOMLDocument, key: str, value: Any) -> list[str]: if not isinstance(node, Table | tomlkit.TOMLDocument): raise FormatError(f"{'.'.join(parts[: depth + 1])} is not a table") last = parts[-1] + if append: + if last not in node: + node.add(last, tomlkit.aot()) + created.append(toml_join(parts)) + tables = node[last] + if not isinstance(tables, AoT): + raise FormatError(f"{toml_join(parts)} is not an array of tables") + if not any(_plain(t) == value for t in tables): + tables.append(_table(value)) + return created node[last] = _table(value) if isinstance(value, dict) else value return created @@ -206,9 +243,31 @@ def _has_comment(item: Any) -> bool: return False -def toml_delete(doc: tomlkit.TOMLDocument, key: str, created: list[str]) -> None: +@_tomlkit_errors +def toml_delete(doc: tomlkit.TOMLDocument, key: str, created: list[str], value: Any = None) -> None: + if key.endswith("/-"): + parts = toml_key(key[:-2]) + chain: list[Any] = [doc] + for part in parts: + chain.append(chain[-1][part]) + tables = chain[-1] + for index, table in enumerate(tables): + if _plain(table) == value: + # tomlkit keeps a comment written before the next header inside this table. + if _has_comment(table): + raise FormatError( + f"a comment sits inside a Kit-owned [[{toml_join(parts)}]] table; " + "move it, then re-run" + ) + del tables[index] + break + if len(tables) or toml_join(parts) not in created: + return + del chain[-2][parts[-1]] + _drop_created(chain[:-1], parts[:-1], created) + return parts = toml_key(key) - chain: list[Any] = [doc] + chain = [doc] for part in parts[:-1]: chain.append(chain[-1][part]) # tomlkit keeps a comment written before the next [header] inside the previous table; @@ -216,6 +275,12 @@ def toml_delete(doc: tomlkit.TOMLDocument, key: str, created: list[str]) -> None if _has_comment(chain[-1].item(parts[-1]) if hasattr(chain[-1], "item") else None): raise FormatError(f"a comment sits inside Kit-owned {key}; move it, then re-run") del chain[-1][parts[-1]] + _drop_created(chain, parts[:-1], created) + + +def _drop_created(chain: list[Any], parents: list[str], created: list[str]) -> None: + """Remove tables Kit created, innermost first, while they are empty.""" + parts = [*parents, ""] for depth in range(len(parts) - 1, 0, -1): if toml_join(parts[:depth]) in created and len(chain[depth]) == 0: if _has_comment(chain[depth]): diff --git a/src/kitctl/native.py b/src/kitctl/native.py index 6410af0..15339bf 100644 --- a/src/kitctl/native.py +++ b/src/kitctl/native.py @@ -239,6 +239,7 @@ def claude_installed(self, relative: str) -> None: "project": install.get("projectPath"), "version": install.get("version"), "sha": install.get("gitCommitSha"), + "path": install.get("installPath"), } ) diff --git a/src/kitctl/resolve.py b/src/kitctl/resolve.py index dc3f925..a0e27cb 100644 --- a/src/kitctl/resolve.py +++ b/src/kitctl/resolve.py @@ -242,6 +242,19 @@ def _effect( } +def _claude_mcp_add(server: str, value: dict[str, Any]) -> str: + """The `claude mcp add --scope user` command for one server; env references stay names.""" + parts = ["claude", "mcp", "add", "--scope", "user"] + if value.get("type") == "http": + parts += ["--transport", "http", server, value["url"]] + for name, ref in value.get("headers", {}).items(): + parts += ["--header", f"{name}: {ref}"] + return shlex.join(parts) + for name, ref in value.get("env", {}).items(): + parts += ["-e", f"{name}={ref}"] + return shlex.join([*parts, server, "--", value["command"], *value["args"]]) + + def _launch(primitive: Primitive) -> tuple[str, list[str]]: data = primitive.data command = str(data["command"]) @@ -282,9 +295,10 @@ def _github_slug(url: str) -> str | None: class Compiler: """Turns the effective primitive set into native effects and gaps for one harness.""" - def __init__(self, harness: Harness, profile: str | None) -> None: + def __init__(self, harness: Harness, profile: str | None, scope: str = "project") -> None: self.harness = harness self.profile = profile + self.scope = scope # "user": targets are relative to home (docs/apply.md#user-scope) self.effects: list[dict[str, Any]] = [] self.gaps: list[dict[str, Any]] = [] self.soft_rules: list[str] = [] @@ -533,6 +547,18 @@ def mcp(self, server: str, entry: dict[str, Any], primitive: Primitive, layer: s command, args = _launch(primitive) value = {"type": "stdio", "command": command, "args": args} value["env"] = {k: f"${{{v['env']}}}" for k, v in sorted(env.items())} + if self.scope == "user": + # User MCP servers live in ~/.claude.json, a state file Claude Code rewrites + # all the time; owner decision (2026-10-21): a remedy, not a write. + self.gap( + primitive.identity, + "mcp_user_file", + "manual-step", + "Claude Code keeps user MCP servers in ~/.claude.json, which Kit " + "does not write", + remedy=_claude_mcp_add(server, value), + ) + return self.add( ".mcp.json", "json", @@ -678,6 +704,15 @@ def hook(self, primitive: Primitive, layer: str) -> None: def skill(self, primitive: Primitive, layer: str) -> None: name = primitive.name.rsplit("/", 1)[-1] + if self.scope == "user" and self.name == "cursor": + self.gap( + primitive.identity, + "skill_dir", + "unsupported", + "Cursor documents no user skills folder", + remedy="install the skill in Cursor by hand", + ) + return target = {"claude": ".claude/skills", "codex": ".agents/skills", "cursor": ".cursor/skills"} self.add( f"{target[self.name]}/{name}/", @@ -776,13 +811,25 @@ def disable(self, primitive: Primitive, layer: str, loaded: dict[str, Primitive] remedy="turn the skill off in Cursor by hand, or disable the whole plugin", soft_rule=soft, ) + elif self.scope == "user": + native = self.native(primitive, "skill") or primitive.name.rsplit("/", 1)[-1] + self.add( + ".codex/config.toml", + "toml", + "skills.config/-", + {"name": native, "enabled": False}, + subject, + layer, + "skill_disable", + ) else: self.gap( subject, "skill_disable", "unsupported", f"{fact.note}; a project loadout cannot disable a single skill", - remedy="disable it in user config by hand, or disable the whole plugin", + remedy="apply a loadout that disables it with `kitctl use --user`, " + "or disable the whole plugin", soft_rule=soft, ) return @@ -800,8 +847,10 @@ def resolve( target: str, harness: str, observed: set[str] | None = None, + scope: str = "project", ) -> dict[str, Any]: - """Resolve target ("loadout" or "loadout:profile") for one harness.""" + """Resolve target ("loadout" or "loadout:profile") for one harness, in a project or in + the user's home (scope "user": targets relative to home).""" if harness not in HARNESSES: raise ValueError(f"unknown harness {harness!r}") name, profile = split_target(target) @@ -839,7 +888,7 @@ def resolve( raise ModelError(sorted(set(problems))) facts = HARNESS_FACTS[harness] - compiler = Compiler(facts, profile) + compiler = Compiler(facts, profile, scope) # Active identities and the layer that enabled them. active: dict[str, str] = {} @@ -1026,7 +1075,9 @@ def resolve( writes = bool(compiler.effects) or ( harness == "cursor" and bool(merged.instructions or compiler.soft_rules) ) - if trust.supported and compiler.effects: + if scope == "user": + pass # user config needs no project trust + elif trust.supported and compiler.effects: compiler.gap( f"loadout:{name}", "project_trust", @@ -1049,7 +1100,15 @@ def resolve( rules = "\n".join(f"- {rule}" for rule in sorted(set(compiler.soft_rules))) text = f"{text}\n\nSoft rules (the harness cannot enforce these):\n{rules}".strip() instructions = None - if text and harness == "cursor": + if text and harness == "cursor" and scope == "user": + compiler.gap( + f"loadout:{name}", + "instruction_file", + "unsupported", + "Cursor's user rules live in its settings, not in a file Kit can own", + remedy="paste the loadout's instructions into Cursor's User Rules by hand", + ) + elif text and harness == "cursor": # A rule file needs its frontmatter first, so Kit owns the whole generated file # (format "file") instead of appending a marked block; `text` is the full file. instructions = { @@ -1063,7 +1122,12 @@ def resolve( compiler.unverified(f"loadout:{name}", "instruction_file") elif text: instructions = { - "target": "CLAUDE.md" if harness == "claude" else "AGENTS.md", + "target": { + ("claude", "project"): "CLAUDE.md", + ("claude", "user"): ".claude/CLAUDE.md", + ("codex", "project"): "AGENTS.md", + ("codex", "user"): ".codex/AGENTS.md", + }[(harness, scope)], "format": "markdown-block", "marker": f"kit:{name}", "layers": [layer for layer, _ in merged.instructions], diff --git a/src/kitctl/starter.py b/src/kitctl/starter.py new file mode 100644 index 0000000..c817442 --- /dev/null +++ b/src/kitctl/starter.py @@ -0,0 +1,81 @@ +"""`kitctl init`: a starter warehouse, so a new user can try Kit without someone else's.""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +KINDS = ("skills", "mcp", "hooks", "plugins", "marketplaces", "tools") + +FILES = { + "README.md": """# My kit warehouse + +Created by `kitctl init`. kitctl reads loadouts from here and writes each harness's own +config in the repos you point it at. + +- `loadouts/`: named bundles. `starter` is a harmless first one: a short instruction block + and a check that git is installed. +- `primitives//`: the skills, MCP servers, hooks, plugins, marketplaces and tools + loadouts refer to (`kind:name`). The format is kitctl's docs/model.md. + +Try it in a repo: + +```sh +kitctl validate +kitctl use starter --dry-run # the plan, nothing written +kitctl use starter # the plan, then [y/N] +kitctl off starter # the undo: restores the original bytes +``` + +Credentials are environment variable names only, never values. Commit this directory to a +private repository; it is your configuration. +""", + "loadouts/starter.yaml": """schema_version: 2 +name: starter +description: A first loadout to try kitctl with; it changes only the instruction files. +extends: [] +requires: [tool:git] +instructions: | + ## Kit + This repo's agent configuration is managed by kitctl. `kitctl status` shows what is on; + `kitctl off ` removes a loadout and restores what was there before. +""", + "primitives/tools/git.yaml": """schema_version: 2 +kind: tool +name: git +description: Git, so each change Kit makes can be reviewed and committed. +check: { command: [git, --version], expect: "^git version " } +install_hint: "https://git-scm.com/downloads" +""", + ".gitignore": "reports/\n", +} + + +class InitError(Exception): + pass + + +def init_warehouse(root: Path, git: bool = True) -> list[str]: + """Create a starter warehouse at root. Refuses a non-empty directory. Returns what it made.""" + if root.exists() and (not root.is_dir() or any(root.iterdir())): + raise InitError(f"{root} already exists and is not empty; choose another path") + made = [] + for kind in KINDS: + (root / "primitives" / kind).mkdir(parents=True, exist_ok=True) + (root / "loadouts").mkdir(parents=True, exist_ok=True) + for rel, text in FILES.items(): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="\n") + made.append(rel) + for kind in KINDS: + if not any((root / "primitives" / kind).iterdir()): + (root / "primitives" / kind / ".gitkeep").write_text("", encoding="utf-8") + if git and shutil.which("git"): + done = subprocess.run( + ["git", "init", "-q", str(root)], capture_output=True, text=True, check=False + ) + if done.returncode == 0: + made.append(".git (git init)") + return made diff --git a/tests/fixtures/expected/brand-studio.audit.codex.json b/tests/fixtures/expected/brand-studio.audit.codex.json index d681104..b5d9ffa 100644 --- a/tests/fixtures/expected/brand-studio.audit.codex.json +++ b/tests/fixtures/expected/brand-studio.audit.codex.json @@ -294,7 +294,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" }, { @@ -303,7 +303,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" }, { @@ -312,7 +312,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" }, { @@ -321,7 +321,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" } ], diff --git a/tests/fixtures/expected/brand-studio.codex.json b/tests/fixtures/expected/brand-studio.codex.json index 3d1467e..414a594 100644 --- a/tests/fixtures/expected/brand-studio.codex.json +++ b/tests/fixtures/expected/brand-studio.codex.json @@ -322,7 +322,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" }, { @@ -331,7 +331,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" }, { @@ -340,7 +340,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" }, { @@ -349,7 +349,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" } ], diff --git a/tests/fixtures/expected/brand-studio.voice.codex.json b/tests/fixtures/expected/brand-studio.voice.codex.json index 320cfac..71b2656 100644 --- a/tests/fixtures/expected/brand-studio.voice.codex.json +++ b/tests/fixtures/expected/brand-studio.voice.codex.json @@ -241,7 +241,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" }, { @@ -250,7 +250,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" }, { @@ -259,7 +259,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" }, { @@ -268,7 +268,7 @@ "capability": "skill_disable", "severity": "unsupported", "reason": "[[skills.config]] in a trusted project config is ignored; the same entry in user config removes the skill; a project loadout cannot disable a single skill", - "remedy": "disable it in user config by hand, or disable the whole plugin", + "remedy": "apply a loadout that disables it with `kitctl use --user`, or disable the whole plugin", "fallback": "managed instruction block (soft rule)" } ], diff --git a/tests/test_cursor.py b/tests/test_cursor.py index 828f598..1c2183a 100644 --- a/tests/test_cursor.py +++ b/tests/test_cursor.py @@ -252,12 +252,12 @@ def test_edited_rule_file_is_drift_not_removed(repo: Path, apply_warehouse: Path assert any(c.startswith(f"drift: {RULE}#kit:cursor-kit") for c in plan.conflicts) -def test_live_apply_is_still_refused( - repo: Path, warehouse: Path, monkeypatch: pytest.MonkeyPatch +def test_sandbox_confines_cursor_writes( + repo: Path, warehouse: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.delenv(engine.SANDBOX_ENV) + monkeypatch.setenv(engine.SANDBOX_ENV, str(tmp_path / "elsewhere")) plan = engine.make_plan(repo, warehouse, intent("cursor-kit")) - with pytest.raises(engine.EngineError, match="sandbox"): + with pytest.raises(engine.EngineError, match="outside"): engine.apply_plan(plan) assert not (repo / ".cursor").exists() diff --git a/tests/test_engine.py b/tests/test_engine.py index c0f288d..0b183de 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1,4 +1,4 @@ -"""Sandbox acceptance for plan/apply/use/off/status/eject (design.md, before live apply).""" +"""Acceptance for plan/apply/use/off/status/eject (design.md), run inside a sandbox.""" import json import os @@ -324,21 +324,20 @@ def test_symlinked_target_is_a_conflict(repo: Path, tmp_path: Path) -> None: assert any("symlink" in c for c in plan.conflicts) -def test_writes_require_the_sandbox( +def test_the_sandbox_confines_writes_when_set( repo: Path, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: plan = engine.make_plan( repo, WAREHOUSE, engine.with_loadout(engine.read_intent(repo), "brand-studio") ) - monkeypatch.delenv(engine.SANDBOX_ENV) - with pytest.raises(engine.EngineError, match="sandbox"): - engine.apply_plan(plan) elsewhere = tmp_path / "elsewhere" elsewhere.mkdir() monkeypatch.setenv(engine.SANDBOX_ENV, str(elsewhere)) with pytest.raises(engine.EngineError, match="outside"): engine.apply_plan(plan) assert not (repo / "kit.lock").exists() + monkeypatch.delenv(engine.SANDBOX_ENV) # unset: writes go where the person points them + assert engine.apply_plan(plan) def test_cli_use_status_off(repo: Path, capsys: pytest.CaptureFixture[str]) -> None: diff --git a/tests/test_formats_aot.py b/tests/test_formats_aot.py new file mode 100644 index 0000000..4f040e8 --- /dev/null +++ b/tests/test_formats_aot.py @@ -0,0 +1,30 @@ +"""TOML arrays of tables owned one table at a time ([[skills.config]] for Codex).""" + +from kitctl import formats +from kitctl.formats import MISSING, detect_style + +USER = '# my codex\nmodel = "gpt-5"\n\n[[skills.config]]\nname = "mine"\nenabled = false # keep\n' +OURS = {"name": "kit-skill", "enabled": False} + + +def test_append_get_and_remove_keep_the_users_entries_and_comments() -> None: + doc = formats.toml_load(USER) + assert formats.toml_get(doc, "skills.config/-", OURS) is MISSING + assert formats.toml_set(doc, "skills.config/-", OURS) == [] # the array was the user's + assert formats.toml_get(doc, "skills.config/-", OURS) == OURS + text = formats.toml_dump(doc, detect_style(USER)) + assert text.startswith(USER) and 'name = "kit-skill"' in text + again = formats.toml_load(text) + formats.toml_delete(again, "skills.config/-", [], OURS) + assert formats.toml_dump(again, detect_style(text)) == USER + + +def test_an_array_kit_created_goes_away_with_its_last_table() -> None: + doc = formats.toml_load('model = "gpt-5"\n') + created = formats.toml_set(doc, "skills.config/-", OURS) + assert created == ["skills", "skills.config"] + text = formats.toml_dump(doc, detect_style("")) + assert "[[skills.config]]" in text and "[skills]" not in text + again = formats.toml_load(text) + formats.toml_delete(again, "skills.config/-", created, OURS) + assert formats.toml_dump(again, detect_style(text)).strip() == 'model = "gpt-5"' diff --git a/tests/test_init.py b/tests/test_init.py new file mode 100644 index 0000000..fdb62bd --- /dev/null +++ b/tests/test_init.py @@ -0,0 +1,51 @@ +"""`kitctl init`: a starter warehouse that validates and applies on its own.""" + +import json +from pathlib import Path + +import pytest + +from kitctl import engine +from kitctl.cli import main + + +def test_init_creates_a_warehouse_that_validates_and_applies( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + kits = tmp_path / "kits" + assert main(["init", str(kits), "--no-git"]) == 0 + assert "created a warehouse" in capsys.readouterr().out + assert (kits / "loadouts" / "starter.yaml").is_file() + assert all((kits / "primitives" / kind).is_dir() for kind in ("skills", "mcp", "hooks")) + assert not (kits / ".git").exists() + assert main(["validate", str(kits), "--json"]) == 0 + assert json.loads(capsys.readouterr().out)["problems"] == [] + + repo = tmp_path / "project" + repo.mkdir() + monkeypatch.setenv(engine.SANDBOX_ENV, str(tmp_path)) + args = ["--repo", str(repo), "--warehouse", str(kits), "--yes"] + assert main(["use", "starter", *args]) == 0 + assert "managed by kitctl" in (repo / "CLAUDE.md").read_text() + assert main(["off", "starter", *args]) == 0 + assert not (repo / "CLAUDE.md").exists() + + +def test_init_defaults_to_kit_warehouse_and_refuses_a_non_empty_directory( + tmp_path: Path, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + kits = tmp_path / "mine" + monkeypatch.setenv("KIT_WAREHOUSE", str(kits)) + assert main(["init", "--no-git"]) == 0 + assert (kits / "README.md").is_file() + before = (kits / "README.md").read_bytes() + assert main(["init"]) == 1 + assert "not empty" in capsys.readouterr().err + assert (kits / "README.md").read_bytes() == before + + +def test_init_accepts_an_empty_directory_and_runs_git_init(tmp_path: Path) -> None: + kits = tmp_path / "empty" + kits.mkdir() + assert main(["init", str(kits)]) == 0 + assert (kits / ".git").exists() or not __import__("shutil").which("git") diff --git a/tests/test_inventory.py b/tests/test_inventory.py index f9543c0..fa40db1 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -64,4 +64,4 @@ def test_version(capsys: pytest.CaptureFixture) -> None: with pytest.raises(SystemExit) as result: main(["--version"]) assert result.value.code == 0 - assert capsys.readouterr().out == "kitctl 0.0.1\n" + assert capsys.readouterr().out == "kitctl 0.1.0\n" diff --git a/tests/test_live.py b/tests/test_live.py deleted file mode 100644 index ea12afe..0000000 --- a/tests/test_live.py +++ /dev/null @@ -1,253 +0,0 @@ -"""Live apply: writes outside $KITCTL_SANDBOX need --live and the owner's opt-in in the repo.""" - -import json -import subprocess -from pathlib import Path - -import pytest - -from kitctl import engine -from kitctl.cli import main - -WAREHOUSE = Path(__file__).parent / "fixtures" / "warehouse" -LIVE_INTENT = "schema_version: 1\nlive: true\nloadouts: []\nharnesses:\n - claude\n - codex\n" - - -def git(repo: Path, *args: str) -> str: - done = subprocess.run( - [ - "git", - "-C", - str(repo), - "-c", - "user.name=Owner", - "-c", - "user.email=owner@example.invalid", - "-c", - "commit.gpgsign=false", - "-c", - "core.autocrlf=false", - *args, - ], - capture_output=True, - text=True, - check=True, - ) - return done.stdout.strip() - - -def tree(root: Path) -> dict[str, bytes]: - return { - p.relative_to(root).as_posix(): p.read_bytes() - for p in sorted(root.rglob("*")) - if p.is_file() and ".git" not in p.relative_to(root).parts - } - - -@pytest.fixture -def live_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """A real-looking repo outside any sandbox: live opted in, committed, on a feature branch.""" - monkeypatch.delenv(engine.SANDBOX_ENV, raising=False) - repo = tmp_path / "project" - repo.mkdir() - git(repo, "init", "-q", "-b", "main") - (repo / "CLAUDE.md").write_bytes(b"# Project\n") - (repo / engine.INTENT).write_bytes(LIVE_INTENT.encode()) - git(repo, "add", "-A") - git(repo, "commit", "-q", "-m", "opt in to live apply") - git(repo, "checkout", "-q", "-b", "feature") - return repo.resolve() - - -def plan_for(repo: Path, target: str = "brand-studio") -> engine.Plan: - return engine.make_plan(repo, WAREHOUSE, engine.with_loadout(engine.read_intent(repo), target)) - - -def test_live_apply_with_every_opt_in_writes_and_off_restores(live_repo: Path) -> None: - baseline = tree(live_repo) - written = engine.apply_plan(plan_for(live_repo), live=True) - assert "CLAUDE.md" in written and (live_repo / "kit.lock").exists() - assert "live: true" in (live_repo / engine.INTENT).read_text() # use keeps the opt-in - git(live_repo, "add", "-A") - git(live_repo, "commit", "-q", "-m", "kit: brand-studio") - plan = engine.make_plan( - live_repo, WAREHOUSE, engine.without_loadout(engine.read_intent(live_repo), "brand-studio") - ) - engine.apply_plan(plan, live=True) - after = tree(live_repo) - assert {k: v for k, v in after.items() if not k.startswith(".kit/")} == baseline - - -def test_without_live_flag_refuses(live_repo: Path) -> None: - with pytest.raises(engine.EngineError, match="pass --live"): - engine.apply_plan(plan_for(live_repo)) - assert not (live_repo / "kit.lock").exists() - - -def test_uncommitted_opt_in_refuses(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(engine.SANDBOX_ENV, raising=False) - repo = tmp_path / "project" - repo.mkdir() - git(repo, "init", "-q", "-b", "main") - (repo / engine.INTENT).write_text(LIVE_INTENT.replace("live: true\n", "")) - git(repo, "add", "-A") - git(repo, "commit", "-q", "-m", "no opt-in") - git(repo, "checkout", "-q", "-b", "feature") - with pytest.raises(engine.EngineError, match="does not say `live: true`"): - engine.apply_plan(plan_for(repo), live=True) - # Saying it in the working copy is not enough: the opt-in is what was committed. - (repo / engine.INTENT).write_text(LIVE_INTENT) - with pytest.raises(engine.EngineError, match="does not say `live: true`"): - engine.apply_plan(plan_for(repo), live=True) - assert not (repo / "kit.lock").exists() - - -def test_live_false_or_a_string_is_not_an_opt_in(live_repo: Path) -> None: - (live_repo / engine.INTENT).write_text(LIVE_INTENT.replace("live: true", 'live: "true"')) - git(live_repo, "commit", "-q", "-am", "quoted") - with pytest.raises(engine.EngineError, match="live must be true or false"): - engine.read_intent(live_repo) - assert any("live: true" in r for r in engine.live_refusals(live_repo)) - - -def test_dirty_tracked_tree_refuses(live_repo: Path) -> None: - (live_repo / "CLAUDE.md").write_text("# Project\n\nunsaved work\n") - with pytest.raises(engine.EngineError, match="uncommitted changes"): - engine.apply_plan(plan_for(live_repo), live=True) - assert (live_repo / "CLAUDE.md").read_text() == "# Project\n\nunsaved work\n" - - -def test_untracked_files_do_not_block(live_repo: Path) -> None: - (live_repo / "notes.txt").write_text("scratch\n") - assert engine.live_refusals(live_repo) == [] - - -def test_default_branch_refuses(live_repo: Path) -> None: - git(live_repo, "checkout", "-q", "main") - with pytest.raises(engine.EngineError, match="main is a default branch"): - engine.apply_plan(plan_for(live_repo), live=True) - - -def test_default_branch_comes_from_origin_head(live_repo: Path, tmp_path: Path) -> None: - remote = tmp_path / "remote.git" - subprocess.run(["git", "init", "-q", "--bare", "-b", "trunk", str(remote)], check=True) - git(live_repo, "branch", "-q", "-m", "feature", "trunk") - git(live_repo, "remote", "add", "origin", str(remote)) - git(live_repo, "push", "-q", "origin", "trunk") - git(live_repo, "remote", "set-head", "origin", "trunk") - assert engine.live_refusals(live_repo) == ["trunk is a default branch; apply on another branch"] - git(live_repo, "checkout", "-q", "main") # main stays protected whatever origin says - assert engine.live_refusals(live_repo) == ["main is a default branch; apply on another branch"] - - -def test_detached_head_refuses(live_repo: Path) -> None: - git(live_repo, "checkout", "-q", "--detach") - with pytest.raises(engine.EngineError, match="HEAD is detached"): - engine.apply_plan(plan_for(live_repo), live=True) - - -def test_not_a_git_repo_refuses(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(engine.SANDBOX_ENV, raising=False) - repo = tmp_path / "plain" - repo.mkdir() - (repo / engine.INTENT).write_text(LIVE_INTENT) - with pytest.raises(engine.EngineError, match="not a git work tree"): - engine.apply_plan(plan_for(repo), live=True) - - -def test_every_reason_is_reported_at_once(live_repo: Path) -> None: - git(live_repo, "checkout", "-q", "main") - (live_repo / engine.INTENT).write_text(LIVE_INTENT.replace("live: true\n", "")) - git(live_repo, "commit", "-q", "-am", "drop opt-in") - (live_repo / "CLAUDE.md").write_text("dirty\n") - reasons = engine.live_refusals(live_repo) - assert len(reasons) == 3, reasons - - -def test_sandbox_outside_still_allows_a_live_opt_in( - live_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - elsewhere = tmp_path / "elsewhere" - elsewhere.mkdir() - monkeypatch.setenv(engine.SANDBOX_ENV, str(elsewhere)) - with pytest.raises(engine.EngineError, match="outside"): - engine.apply_plan(plan_for(live_repo)) - assert engine.apply_plan(plan_for(live_repo), live=True) - - -def test_interrupted_live_apply_recovers_on_its_own_dirty_files( - live_repo: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - plan = plan_for(live_repo) - original = engine._finish - monkeypatch.setattr(engine, "_finish", lambda *a: (_ for _ in ()).throw(KeyboardInterrupt)) - with pytest.raises(KeyboardInterrupt): - engine.apply_plan(plan, live=True) - monkeypatch.setattr(engine, "_finish", original) - assert (live_repo / engine.JOURNAL).exists() - # Only recovery may run on the journal's own uncommitted files; a fresh apply may not. - assert any("uncommitted" in r for r in engine.live_refusals(live_repo)) - assert engine.live_refusals(live_repo, recovering=True) == [] - assert engine.recover(live_repo, live=True) - assert any("uncommitted" in r for r in engine.live_refusals(live_repo, recovering=True)) - - -def test_recovery_refuses_when_other_files_are_dirty( - live_repo: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - (live_repo / "README.md").write_bytes(b"readme\n") - git(live_repo, "add", "README.md") - git(live_repo, "commit", "-q", "-m", "readme") - original = engine._finish - monkeypatch.setattr(engine, "_finish", lambda *a: (_ for _ in ()).throw(KeyboardInterrupt)) - with pytest.raises(KeyboardInterrupt): - engine.apply_plan(plan_for(live_repo), live=True) - monkeypatch.setattr(engine, "_finish", original) - (live_repo / "README.md").write_bytes(b"the owner's unsaved edit\n") - with pytest.raises(engine.EngineError, match="uncommitted"): - engine.recover(live_repo, live=True) - - -def test_inherited_git_dir_does_not_answer_for_the_repo( - live_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - other = tmp_path / "other" - other.mkdir() - git(other, "init", "-q", "-b", "main") - (other / engine.INTENT).write_bytes(b"schema_version: 1\n") - git(other, "add", "-A") - git(other, "commit", "-q", "-m", "no opt-in") - monkeypatch.setenv("GIT_DIR", str(live_repo / ".git")) # as inside a git hook - reasons = engine.live_refusals(other.resolve()) - assert any("live: true" in r for r in reasons) and any("main" in r for r in reasons) - - -def test_crlf_kit_yaml_round_trips(live_repo: Path) -> None: - (live_repo / engine.INTENT).write_bytes(LIVE_INTENT.replace("\n", "\r\n").encode()) - git(live_repo, "commit", "-q", "-am", "crlf") - baseline = tree(live_repo) - engine.apply_plan(plan_for(live_repo), live=True) - assert b"\r\n" in (live_repo / engine.INTENT).read_bytes() - git(live_repo, "add", "-A") - git(live_repo, "commit", "-q", "-m", "kit: brand-studio") - plan = engine.make_plan( - live_repo, WAREHOUSE, engine.without_loadout(engine.read_intent(live_repo), "brand-studio") - ) - engine.apply_plan(plan, live=True) - after = tree(live_repo) - assert {k: v for k, v in after.items() if not k.startswith(".kit/")} == baseline - - -def test_cli_live_flag(live_repo: Path, capsys: pytest.CaptureFixture[str]) -> None: - args = ["--repo", str(live_repo), "--warehouse", str(WAREHOUSE)] - baseline = tree(live_repo) - assert main(["use", "brand-studio", *args, "--dry-run"]) == 0 # a dry run needs no opt-in - assert main(["use", "brand-studio", *args, "--yes"]) == 1 - assert "pass --live" in capsys.readouterr().err - assert tree(live_repo) == baseline - assert main(["use", "brand-studio", *args, "--yes", "--live"]) == 0 - capsys.readouterr() - assert main(["status", *args, "--json"]) == 0 - assert all(e["state"] == "ok" for e in json.loads(capsys.readouterr().out)["entries"]) - assert main(["off", "brand-studio", *args, "--yes", "--live"]) == 1 # not committed yet - assert "uncommitted changes" in capsys.readouterr().err diff --git a/tests/test_setup.py b/tests/test_setup.py new file mode 100644 index 0000000..f8e8e20 --- /dev/null +++ b/tests/test_setup.py @@ -0,0 +1,101 @@ +"""Setup steps: resolved in the project, run by `use` after a confirmation, never Kit-owned.""" + +import json +import sys +from pathlib import Path + +import pytest + +from kitctl import engine +from kitctl.cli import main + + +@pytest.fixture +def warehouse(tmp_path: Path) -> Path: + root = tmp_path / "kits" + (root / "loadouts").mkdir(parents=True) + (root / "primitives").mkdir() + script = "open('made.txt', 'w').write('made')" + (root / "loadouts" / "maker.yaml").write_text( + "schema_version: 2\nname: maker\ndescription: Makes a file.\nextends: []\n" + "instructions: Uses maker.\n" + f"setup:\n - run: [{json.dumps(sys.executable)}, -c, {json.dumps(script)}]\n" + " when: file-exists:INPUT.md\n outputs: [made.txt]\n" + ) + return root + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv(engine.SANDBOX_ENV, str(tmp_path)) + repo = tmp_path / "project" + repo.mkdir() + return repo.resolve() + + +def test_when_and_plugin_paths_resolve_or_say_why_not(project: Path, tmp_path: Path) -> None: + home = tmp_path / "home" + steps = [ + {"run": ["node", "${plugin:brand-builder}/scripts/x.mjs"], "harness": "any"}, + {"run": ["node", "${plugin:missing}/x.mjs"], "harness": "any"}, + {"run": ["echo"], "when": "file-exists:BRAND.md", "harness": "any"}, + ] + installed = home / ".claude" / "plugins" / "installed_plugins.json" + installed.parent.mkdir(parents=True) + installed.write_text( + json.dumps( + { + "version": 2, + "plugins": { + "brand-builder@brand-builder": [ + {"scope": "user", "installPath": "/opt/bb", "version": "0.3.1"} + ] + }, + } + ) + ) + runs = engine.setup_runs(project, steps, home) + assert runs[0]["argv"] == ["node", "/opt/bb/scripts/x.mjs"] + assert "missing is not installed" in runs[1]["skip"] + assert runs[2]["skip"] == "BRAND.md not found" + (project / "BRAND.md").write_text("# Brand\n") + assert engine.setup_runs(project, steps[2:], home)[0]["argv"] == ["echo"] + + +def test_use_runs_setup_with_yes_and_off_leaves_its_outputs( + project: Path, warehouse: Path, capsys: pytest.CaptureFixture[str] +) -> None: + args = ["--repo", str(project), "--warehouse", str(warehouse), "--yes"] + assert main(["use", "maker", *args]) == 0 + assert "setup skipped (INPUT.md not found)" in capsys.readouterr().out + assert not (project / "made.txt").exists() + (project / "INPUT.md").write_text("in\n") + assert main(["use", "maker", *args]) == 0 # again: config is current, setup runs now + assert (project / "made.txt").read_text() == "made" + assert main(["off", "maker", *args]) == 0 + assert (project / "made.txt").exists() # setup outputs are the tool's, not Kit's + + +def test_setup_asks_separately_on_a_terminal( + project: Path, warehouse: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (project / "INPUT.md").write_text("in\n") + answers = iter(["y", "n"]) + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda: next(answers)) + assert main(["use", "maker", "--repo", str(project), "--warehouse", str(warehouse)]) == 0 + assert (project / "CLAUDE.md").exists() # the config was written + assert not (project / "made.txt").exists() # the setup step was declined + + +def test_a_failing_setup_step_fails_the_command( + project: Path, warehouse: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (project / "INPUT.md").write_text("in\n") + loadout = warehouse / "loadouts" / "maker.yaml" + loadout.write_text( + loadout.read_text().replace("open('made.txt', 'w').write('made')", "raise SystemExit(3)") + ) + args = ["--repo", str(project), "--warehouse", str(warehouse), "--yes"] + assert main(["use", "maker", *args]) == 1 + assert "setup step exited 3" in capsys.readouterr().err diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..ef262a3 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,101 @@ +"""Skill directories from the warehouse: copied as owned files, updated, and removed by off.""" + +import shutil +from pathlib import Path + +import pytest + +from kitctl import engine + +WAREHOUSE = Path(__file__).parent / "fixtures" / "warehouse" + + +@pytest.fixture +def skill_warehouse(tmp_path: Path) -> Path: + root = tmp_path / "kits" + (root / "primitives" / "skills" / "hello" / "references").mkdir(parents=True) + (root / "loadouts").mkdir() + (root / "primitives" / "skills" / "hello.yaml").write_bytes( + b"schema_version: 2\nkind: skill\nname: hello\ndescription: Says hello.\n" + ) + (root / "primitives" / "skills" / "hello" / "SKILL.md").write_bytes( + b"---\nname: hello\ndescription: Says hello.\n---\nSay hello.\n" + ) + # A CRLF asset, as a Windows checkout of the warehouse would have it. + (root / "primitives" / "skills" / "hello" / "references" / "notes.md").write_bytes( + b"Notes.\r\n" + ) + (root / "loadouts" / "greet.yaml").write_text( + "schema_version: 2\nname: greet\ndescription: Greets.\nextends: []\nskills: [skill:hello]\n" + ) + return root + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setenv(engine.SANDBOX_ENV, str(tmp_path)) + repo = tmp_path / "project" + repo.mkdir() + (repo / "CLAUDE.md").write_bytes(b"# Project\n") + return repo.resolve() + + +def files(root: Path) -> dict[str, bytes]: + return { + p.relative_to(root).as_posix(): p.read_bytes() + for p in sorted(root.rglob("*")) + if p.is_file() and ".kit" not in p.relative_to(root).parts + } + + +def plan(repo: Path, warehouse: Path, on: bool = True) -> engine.Plan: + intent = engine.read_intent(repo) + change = engine.with_loadout(intent, "greet") if on else engine.without_loadout(intent, "greet") + return engine.make_plan(repo, warehouse, change) + + +def test_a_skill_directory_is_copied_for_each_harness_and_off_removes_it( + project: Path, skill_warehouse: Path +) -> None: + baseline = files(project) + p = plan(project, skill_warehouse) + assert p.conflicts == [] + engine.apply_plan(p) + for root in (".claude/skills/hello", ".agents/skills/hello"): + assert (project / root / "SKILL.md").read_text().endswith("Say hello.\n") + assert (project / root / "references" / "notes.md").read_bytes() == b"Notes.\n" + assert engine.status(project, skill_warehouse)["entries"] + assert all(e["state"] == "ok" for e in engine.status(project, skill_warehouse)["entries"]) + engine.apply_plan(plan(project, skill_warehouse, on=False)) + assert files(project) == baseline + assert not (project / ".claude").exists() and not (project / ".agents").exists() + + +def test_a_changed_skill_in_the_warehouse_updates_the_copy( + project: Path, skill_warehouse: Path +) -> None: + engine.apply_plan(plan(project, skill_warehouse)) + (skill_warehouse / "primitives" / "skills" / "hello" / "SKILL.md").write_bytes( + b"---\nname: hello\ndescription: Says hello.\n---\nSay hello twice.\n" + ) + engine.apply_plan(plan(project, skill_warehouse)) + assert "twice" in (project / ".claude/skills/hello/SKILL.md").read_text() + + +def test_an_existing_unowned_skill_file_is_a_conflict(project: Path, skill_warehouse: Path) -> None: + (project / ".claude/skills/hello").mkdir(parents=True) + (project / ".claude/skills/hello/SKILL.md").write_text("mine\n") + p = plan(project, skill_warehouse) + assert any(".claude/skills/hello/SKILL.md" in c for c in p.conflicts) + with pytest.raises(engine.ConflictError): + engine.apply_plan(p) + assert (project / ".claude/skills/hello/SKILL.md").read_text() == "mine\n" + + +def test_a_skill_without_skill_md_or_with_binary_assets_is_a_conflict( + project: Path, skill_warehouse: Path +) -> None: + (skill_warehouse / "primitives" / "skills" / "hello" / "logo.png").write_bytes(b"\x89PNG\xff") + assert any("not UTF-8" in c for c in plan(project, skill_warehouse).conflicts) + shutil.rmtree(skill_warehouse / "primitives" / "skills" / "hello") + assert any("no skill directory" in c for c in plan(project, skill_warehouse).conflicts) diff --git a/tests/test_user_scope.py b/tests/test_user_scope.py new file mode 100644 index 0000000..6945446 --- /dev/null +++ b/tests/test_user_scope.py @@ -0,0 +1,152 @@ +"""User scope: the same plan/apply/off machinery with home as the root (docs/apply.md).""" + +from pathlib import Path + +import pytest + +from kitctl import engine +from kitctl.cli import main + +WAREHOUSE = Path(__file__).parent / "fixtures" / "warehouse" +SETTINGS = b'{\n "theme": "dark"\n}\n' +CODEX = b'# mine\nmodel = "gpt-5"\n\n[[skills.config]]\nname = "mine"\nenabled = false\n' + + +def tree(root: Path) -> dict[str, bytes]: + return { + p.relative_to(root).as_posix(): p.read_bytes() + for p in sorted(root.rglob("*")) + if p.is_file() + } + + +@pytest.fixture +def home() -> Path: + """The conftest's temporary HOME, with config the user wrote themselves.""" + root = Path.home() + (root / ".claude").mkdir(parents=True, exist_ok=True) + (root / ".codex").mkdir(parents=True, exist_ok=True) + (root / ".claude" / "settings.json").write_bytes(SETTINGS) + (root / ".codex" / "config.toml").write_bytes(CODEX) + return root.resolve() + + +def run(command: str, *extra: str) -> int: + return main([command, "brand-studio", "--user", "--warehouse", str(WAREHOUSE), *extra]) + + +def test_use_writes_user_config_and_off_restores_home_byte_for_byte(home: Path) -> None: + baseline = tree(home) + assert run("use", "--yes") == 0 + settings = (home / ".claude" / "settings.json").read_text() + assert '"theme": "dark"' in settings and "brand-builder@brand-builder" in settings + codex = (home / ".codex" / "config.toml").read_text() + assert codex.startswith(CODEX.decode()) and 'name = "ui-ux-pro-max"' in codex + assert "kit:begin brand-studio" in (home / ".claude" / "CLAUDE.md").read_text() + assert "kit:begin brand-studio" in (home / ".codex" / "AGENTS.md").read_text() + assert (home / ".kit" / "kit.yaml").is_file() and (home / ".kit" / "kit.lock").is_file() + for loose in ("kit.yaml", "kit.lock", "CLAUDE.md", "AGENTS.md", ".mcp.json", ".claude.json"): + assert not (home / loose).exists(), loose + assert all(e["state"] == "ok" for e in engine.status(home, WAREHOUSE)["entries"]) + assert run("off", "--yes") == 0 + assert tree(home) == baseline # the user's own [[skills.config]] entry survives + + +def test_claude_mcp_is_a_remedy_not_a_write(home: Path) -> None: + intent = engine.with_loadout(engine.read_intent(home), "brand-studio") + plan = engine.make_plan(home, WAREHOUSE, intent) + remedies = [g["remedy"] for g in plan.gaps if g["capability"] == "mcp_user_file"] + assert any(r.startswith("claude mcp add --scope user chrome-devtools -- npx") for r in remedies) + assert not any(t.endswith(".claude.json") or t.endswith(".mcp.json") for t in plan.changes) + + +def test_claude_config_dir_moves_the_targets_within_home( + home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home / "alt-claude")) + assert run("use", "--yes") == 0 + assert "brand-builder@brand-builder" in (home / "alt-claude" / "settings.json").read_text() + assert (home / "alt-claude" / "CLAUDE.md").is_file() + assert (home / ".claude" / "settings.json").read_bytes() == SETTINGS + + +def test_a_config_dir_outside_home_is_a_conflict( + home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "elsewhere")) + intent = engine.with_loadout(engine.read_intent(home), "brand-studio") + plan = engine.make_plan(home, WAREHOUSE, intent) + assert any("CODEX_HOME" in c and "outside home" in c for c in plan.conflicts) + + +def test_user_and_repo_together_is_a_usage_error( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + args = ["--user", "--repo", str(tmp_path), "--warehouse", str(WAREHOUSE)] + assert main(["plan", *args]) == 2 + assert "pass one" in capsys.readouterr().err + + +def test_setup_steps_are_listed_not_run_and_the_user_is_warned( + home: Path, capsys: pytest.CaptureFixture[str] +) -> None: + assert run("use", "--yes") == 0 + captured = capsys.readouterr() + assert "setup steps run in projects" in captured.out + assert "warning: user scope" in captured.err + + +def test_cursor_has_no_user_rule_file(home: Path) -> None: + intent = {"schema_version": 1, "loadouts": ["brand-studio"], "harnesses": ["cursor"]} + plan = engine.make_plan(home, WAREHOUSE, intent) + assert not any(".cursor/rules" in t for t in plan.changes) + assert any(g["capability"] == "instruction_file" for g in plan.gaps) + + +def test_a_comment_inside_a_kit_table_blocks_off_instead_of_vanishing(home: Path) -> None: + assert run("use", "--yes") == 0 + config = home / ".codex" / "config.toml" + text = config.read_text() + marker = 'name = "ui-ux-pro-max"\nenabled = false\n' + assert marker in text + config.write_text(text.replace(marker, marker + "# my precious comment\n")) + assert run("off", "--yes") == 1 + assert "# my precious comment" in config.read_text() + + +def test_a_split_array_of_tables_is_a_conflict_not_a_crash( + home: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (home / ".codex" / "config.toml").write_bytes( + b'[[skills.config]]\nname = "a"\nenabled = false\n\n[tui]\nx = 1\n\n' + b'[[skills.config]]\nname = "b"\nenabled = false\n' + ) + assert run("use", "--yes") == 1 + assert "kitctl:" in capsys.readouterr().err + + +def test_a_bad_config_dir_does_not_block_off( + home: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + baseline = tree(home) + assert run("use", "--yes") == 0 + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(tmp_path / "elsewhere")) + assert run("off", "--yes") == 0 + assert tree(home) == baseline + + +def test_other_files_in_dot_kit_survive(home: Path) -> None: + (home / ".kit").mkdir() + (home / ".kit" / "other-tool.db").write_bytes(b"theirs") + baseline = tree(home) + assert run("use", "--yes") == 0 + assert run("off", "--yes") == 0 + assert tree(home) == baseline + + +def test_config_dir_cannot_be_home_or_kits_state( + home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(home)) + intent = engine.with_loadout(engine.read_intent(home), "brand-studio") + assert any("not home" in c for c in engine.make_plan(home, WAREHOUSE, intent).conflicts) diff --git a/tests/test_write_gate.py b/tests/test_write_gate.py new file mode 100644 index 0000000..c8de4f7 --- /dev/null +++ b/tests/test_write_gate.py @@ -0,0 +1,211 @@ +"""Writes in a real repository: anywhere, after the plan and a confirmation; git state warns.""" + +import json +import os +import subprocess +from pathlib import Path + +import pytest + +from kitctl import engine +from kitctl.cli import main + +WAREHOUSE = Path(__file__).parent / "fixtures" / "warehouse" +INTENT = "schema_version: 1\nloadouts: []\nharnesses:\n - claude\n - codex\n" + + +def clean_env() -> dict[str, str]: + """No inherited GIT_DIR and friends: under a hook they would aim at the developer's repo.""" + return {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + + +def git(repo: Path, *args: str) -> str: + done = subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Owner", + "-c", + "user.email=owner@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + *args, + ], + capture_output=True, + text=True, + check=True, + env=clean_env(), + ) + return done.stdout.strip() + + +def tree(root: Path) -> dict[str, bytes]: + return { + p.relative_to(root).as_posix(): p.read_bytes() + for p in sorted(root.rglob("*")) + if p.is_file() and not ({".git", ".kit"} & set(p.relative_to(root).parts)) + } + + +@pytest.fixture +def real_repo(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A repository outside any sandbox, committed on a feature branch.""" + monkeypatch.delenv(engine.SANDBOX_ENV, raising=False) + repo = tmp_path / "project" + repo.mkdir() + git(repo, "init", "-q", "-b", "main") + (repo / "CLAUDE.md").write_bytes(b"# Project\n") + (repo / engine.INTENT).write_bytes(INTENT.encode()) + git(repo, "add", "-A") + git(repo, "commit", "-q", "-m", "start") + git(repo, "checkout", "-q", "-b", "feature") + return repo.resolve() + + +def plan_for(repo: Path, target: str = "brand-studio") -> engine.Plan: + return engine.make_plan(repo, WAREHOUSE, engine.with_loadout(engine.read_intent(repo), target)) + + +def off_plan(repo: Path, name: str = "brand-studio") -> engine.Plan: + return engine.make_plan(repo, WAREHOUSE, engine.without_loadout(engine.read_intent(repo), name)) + + +def test_writes_in_a_real_repo_and_off_restores_the_bytes(real_repo: Path) -> None: + baseline = tree(real_repo) + assert "CLAUDE.md" in engine.apply_plan(plan_for(real_repo)) + engine.apply_plan(off_plan(real_repo)) + assert tree(real_repo) == baseline + + +def test_no_git_still_writes_and_off_is_the_undo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv(engine.SANDBOX_ENV, raising=False) + repo = tmp_path / "plain" + repo.mkdir() + (repo / "CLAUDE.md").write_bytes(b"# Mine\n") + baseline = tree(repo) + assert any("not a git repository" in w for w in engine.write_warnings(repo)) + engine.apply_plan(plan_for(repo)) + engine.apply_plan(off_plan(repo)) + assert tree(repo) == baseline + + +def test_git_state_warns_and_never_blocks(real_repo: Path) -> None: + assert engine.write_warnings(real_repo) == [] + (real_repo / "notes.txt").write_text("untracked\n") + assert engine.write_warnings(real_repo) == [] # untracked files are not the owner's edits + (real_repo / "CLAUDE.md").write_bytes(b"# Project\n\nunsaved\n") + git(real_repo, "checkout", "-q", "main") + warnings = engine.write_warnings(real_repo) + assert any("uncommitted" in w for w in warnings) and any("on main" in w for w in warnings) + assert engine.apply_plan(plan_for(real_repo)) + assert b"unsaved" in (real_repo / "CLAUDE.md").read_bytes() + + +def test_the_remote_default_branch_warns(real_repo: Path, tmp_path: Path) -> None: + remote = tmp_path / "remote.git" + subprocess.run( + ["git", "init", "-q", "--bare", "-b", "trunk", str(remote)], check=True, env=clean_env() + ) + git(real_repo, "branch", "-q", "-m", "feature", "trunk") + git(real_repo, "remote", "add", "origin", str(remote)) + git(real_repo, "push", "-q", "origin", "trunk") + git(real_repo, "remote", "set-head", "origin", "trunk") + assert any("on trunk" in w for w in engine.write_warnings(real_repo)) + + +def test_inherited_git_dir_does_not_answer_for_the_repo( + real_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + other = tmp_path / "other" + other.mkdir() + git(other, "init", "-q", "-b", "main") + (other / "a.txt").write_text("a\n") + git(other, "add", "-A") + git(other, "commit", "-q", "-m", "a") + monkeypatch.setenv("GIT_DIR", str(real_repo / ".git")) # as inside a git hook + assert any("on main" in w for w in engine.write_warnings(other.resolve())) + + +def test_the_sandbox_still_confines_writes_when_set( + real_repo: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + monkeypatch.setenv(engine.SANDBOX_ENV, str(elsewhere)) + with pytest.raises(engine.EngineError, match="outside"): + engine.apply_plan(plan_for(real_repo)) + assert not (real_repo / "kit.lock").exists() + + +def test_an_old_live_opt_in_still_reads(real_repo: Path) -> None: + (real_repo / engine.INTENT).write_bytes( + INTENT.replace("loadouts", "live: true\nloadouts").encode() + ) + assert engine.read_intent(real_repo)["loadouts"] == [] + engine.apply_plan(plan_for(real_repo)) + assert b"live: true" in (real_repo / engine.INTENT).read_bytes() + + +def test_crlf_kit_yaml_round_trips(real_repo: Path) -> None: + (real_repo / engine.INTENT).write_bytes(INTENT.replace("\n", "\r\n").encode()) + baseline = tree(real_repo) + engine.apply_plan(plan_for(real_repo)) + assert b"\r\n" in (real_repo / engine.INTENT).read_bytes() + engine.apply_plan(off_plan(real_repo)) + assert tree(real_repo) == baseline + + +def test_cli_asks_before_writing( + real_repo: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + args = ["--repo", str(real_repo), "--warehouse", str(WAREHOUSE)] + baseline = tree(real_repo) + assert main(["use", "brand-studio", *args, "--dry-run"]) == 0 + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + assert main(["use", "brand-studio", *args]) == 1 # not a terminal and no --yes + assert "re-run with --yes" in capsys.readouterr().err + assert tree(real_repo) == baseline + + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("builtins.input", lambda: "n") + assert main(["use", "brand-studio", *args]) == 1 + assert "[y/N]" in capsys.readouterr().err + assert tree(real_repo) == baseline + + monkeypatch.setattr("builtins.input", lambda: "y") + assert main(["use", "brand-studio", *args, "--json"]) == 0 + out = capsys.readouterr().out + assert "[y/N]" not in out # the prompt goes to stderr; --json stays parseable + capsys.readouterr() + assert main(["status", *args, "--json"]) == 0 + assert all(e["state"] == "ok" for e in json.loads(capsys.readouterr().out)["entries"]) + assert main(["off", "brand-studio", *args, "--yes"]) == 0 + assert tree(real_repo) == baseline + + +def test_cli_warns_and_accepts_the_old_live_flag( + real_repo: Path, capsys: pytest.CaptureFixture[str] +) -> None: + git(real_repo, "checkout", "-q", "main") + args = ["--repo", str(real_repo), "--warehouse", str(WAREHOUSE)] + assert main(["use", "brand-studio", *args, "--yes", "--live"]) == 0 + err = capsys.readouterr().err + assert "--live is no longer needed" in err and "warning: on main" in err + + +def test_a_symlinked_state_directory_is_refused(real_repo: Path, tmp_path: Path) -> None: + outside = tmp_path / "outside" + outside.mkdir() + try: + (real_repo / ".kit").symlink_to(outside, target_is_directory=True) + except OSError: + pytest.skip("symlinks unavailable") + with pytest.raises(engine.EngineError, match="symlink"): + engine.apply_plan(plan_for(real_repo)) + assert list(outside.iterdir()) == [] diff --git a/uv.lock b/uv.lock index b6cecdd..31f96ff 100644 --- a/uv.lock +++ b/uv.lock @@ -26,7 +26,7 @@ wheels = [ [[package]] name = "kitctl" -version = "0.0.1" +version = "0.1.0" source = { editable = "." } dependencies = [ { name = "ruamel-yaml" },