diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd3222..5d1cce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ Kit writes: loadouts apply to a project or to your home with a plan, a confirmation and a byte-for-byte undo. +- Worker agents: a new `agent` primitive kind (`primitives/agents/.yaml` plus a + `/` folder with the author's ready `claude.md` and/or `codex.toml`) and a loadout + `agents:` list (profiles can narrow it). Kit copies `claude.md` to + `.claude/agents/.md` and `codex.toml` to `.codex/agents/.toml` as owned + whole files, in a project or (`--user`) under `~/.claude` and `$CODEX_HOME`; it never + converts between formats. Codex project agents carry an `unverified` gap and Cursor an + `unsupported` one. `validate` reports a missing folder or harness file, TOML that does + not parse, a missing Codex `name`/`description`/`developer_instructions`, and a + `claude.md` frontmatter name that differs from the file name. Inventory lists + `.claude/agents/*.md` and `.codex/agents/*.toml`; `init` makes `primitives/agents/`; + forge and `vendor` refuse agents. +- Listing cost: `doctor` estimates each harness's always-on listing (instruction block, + listed skills and agents as `name: description`, `ceil(chars / 4)` as in Gestalt) for + `--loadout` plus the loadouts in each root's `kit.yaml`, and names what it cannot + measure (plugin skills it cannot read, MCP servers). Skills with + `disable-model-invocation: true` and skills the loadout disables cost nothing. A loadout + or profile `budget: {tokens, skills}` turns an over-budget listing into a `budget` + warning (never an error); the child layer or profile overrides its parent. + +- Fixes from reviewing this release: `eject` no longer refuses an instruction block (or a + skill or agent file) that merely mentions kitctl, which made the starter loadout + impossible to eject; a malformed `kit.yaml` is an error message instead of a traceback; + two whole files for one target (`acme/review` and `team/review` both landing as + `review`) are a conflict instead of one silently overwriting the other; a folder (or + other non-file) where Kit would write a file is a conflict at plan time instead of a + failure halfway through apply; a `.git` folder inside a warehouse skill is not copied. - 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 diff --git a/README.md b/README.md index 49ac01d..23ed558 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Kit (`kitctl`) -Kit manages the skills, MCP servers, hooks, plugins and instruction blocks your coding +Kit manages the skills, agents, 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` @@ -12,7 +12,9 @@ puts back exactly what was there. - 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). +- copy warehouse skill folders and worker agents, and run a loadout's setup steps (after + asking); +- estimate each harness's always-on listing and hold it to a loadout `budget` (`doctor`). Kit never writes trust: approving hooks, MCP servers and projects stays with you. @@ -64,6 +66,10 @@ name. It never fixes anything or starts an MCP server: uv run kitctl doctor --loadout brand-studio --root ~ --root path/to/repo ``` +It also estimates the always-on listing each harness carries for the loadout plus the +roots' `kit.yaml` loadouts (instruction block, listed skills and agents, `ceil(chars / 4)`) +and warns when it is over a loadout's `budget` (see [the model](docs/model.md#budget)). + `use`, `off`, `plan`, `apply`, `status` and `eject` manage a project's native 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 diff --git a/docs/apply.md b/docs/apply.md index 0bdbb7a..127f6ac 100644 --- a/docs/apply.md +++ b/docs/apply.md @@ -64,13 +64,14 @@ original bytes like it does in a project. | Project target | User target | |---|---| -| `.claude/settings.json`, `.claude/skills//` | `~/.claude/…` (or `$CLAUDE_CONFIG_DIR/…`) | +| `.claude/settings.json`, `.claude/skills//`, `.claude/agents/.md` | `~/.claude/…` (or `$CLAUDE_CONFIG_DIR/…`) | | `CLAUDE.md` block | `~/.claude/CLAUDE.md` | -| `.codex/config.toml`, `.codex/hooks.json` | `~/.codex/…` (or `$CODEX_HOME/…`) | +| `.codex/config.toml`, `.codex/hooks.json`, `.codex/agents/.toml` | `~/.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 | +| agents on Cursor | a gap in both scopes: no Cursor agent location is verified | 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 @@ -89,6 +90,12 @@ comment inside a `[[skills.config]]` table Kit owns blocks `off` until you move 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. +**Agents.** An agent primitive's ready files are copied whole: `claude.md` to +`.claude/agents/.md` and `codex.toml` to `.codex/agents/.toml`, each an owned +file with LF line endings. Updates, drift and `off` work as for skills, and an existing file +Kit did not write is a conflict. Codex project agents carry an `unverified` gap (only +`$CODEX_HOME/agents/` has been seen loading); Cursor gets an `unsupported` gap. + **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 @@ -109,6 +116,8 @@ Every write takes `--dry-run`. Without a terminal and without `--yes`, nothing i | Cursor | `.cursor/mcp.json` | `mcpServers.`, env and headers as `${env:NAME}` | | Cursor | `.cursor/hooks.json` | `version`, and `hooks.` entries (array elements owned by value) | | Cursor | `.cursor/rules/kit-NAME.mdc` | the whole generated file (format `file`): frontmatter `alwaysApply: true`, then the instructions | +| Claude, Codex, Cursor | `.claude/skills//`, `.agents/skills//`, `.cursor/skills//` | each skill asset, a whole owned file | +| Claude, Codex | `.claude/agents/.md`, `.codex/agents/.toml` | the agent's ready file for that harness, a whole owned file | | Claude, Codex | `CLAUDE.md` / `AGENTS.md` | `` … `` | Every Cursor fact is documented only, never verified (see diff --git a/docs/harness-reference.md b/docs/harness-reference.md index 725e29b..d5f5ed3 100644 --- a/docs/harness-reference.md +++ b/docs/harness-reference.md @@ -234,6 +234,18 @@ on Codex 0.163.0 (2026-10-20): Plugin hook commands get `${CLAUDE_PLUGIN_ROOT}` instead. Earlier text said Codex sets all four variables everywhere. +## Worker agents (`agent_file`, 2026-10-21) + +Kit copies an agent author's ready file per harness; it never renders one format into the +other. The facts in `src/kitctl/harness.py`: + +| Harness | Fact | Target | Evidence | What it rests on | +|---|---|---|---|---| +| Claude Code | `agent_file`, `agent_user_file` | `.claude/agents/.md`, `~/.claude/agents/.md` | documented | [Subagents](https://code.claude.com/docs/en/sub-agents): Markdown with `name` and `description` frontmatter, project and user folders. Like `skill_dir`, not flagged as a gap. | +| Codex | `agent_user_file` | `$CODEX_HOME/agents/.toml` | verified | String `name`, `description` and `developer_instructions` load in Codex 0.163; checked by a sibling project's installer (Gestalt, Cairn), not in Kit's own sandbox. Codex ignores a file missing `developer_instructions`. | +| Codex | `agent_file` | `.codex/agents/.toml` | documented | Assumed by analogy with the user folder (and with `.codex/skills`); no run has shown Codex loading it, so every project agent carries an `unverified` gap. | +| Cursor | `agent_file` | – | documented (absence) | No agent file location in the sources below; an `unsupported` gap with a by-hand remedy. | + ## Cursor (documented only, 2026-10-20) No Cursor CLI is installed in the build container, so **nothing about Cursor is diff --git a/docs/model.md b/docs/model.md index 41c311c..b4b7e23 100644 --- a/docs/model.md +++ b/docs/model.md @@ -5,8 +5,8 @@ is for review: no generator writes native config from it yet. ## Identities and paths -A reference is `kind:name`, where kind is `skill`, `mcp`, `hook`, `plugin`, -`marketplace` or `tool`. A name is a lowercase slug with at most one namespace +A reference is `kind:name`, where kind is `skill`, `agent`, `mcp`, `hook`, +`plugin`, `marketplace` or `tool`. A name is a lowercase slug with at most one namespace segment (`anthropic/brand-guidelines`). References containing `..`, a backslash, or an absolute path are `path-traversal` errors. So are files that resolve outside the warehouse through a symlink. @@ -14,6 +14,7 @@ resolve outside the warehouse through a symlink. | Kind | File | Assets | |---|---|---| | skill | `primitives/skills/.yaml` | `primitives/skills//` (needs `SKILL.md`) | +| agent | `primitives/agents/.yaml` | `primitives/agents//` (`claude.md`, `codex.toml`) | | mcp | `primitives/mcp/.yaml` | – | | hook | `primitives/hooks/.yaml` | optional `/` | | plugin | `primitives/plugins/.yaml` | optional `/` | @@ -56,6 +57,7 @@ Kind-specific fields: | hook | `event`, `matcher`, `command` (list), `timeout`, `blocking`, `trust` (only `requires-user-review`), `unless_capability`, `heartbeat` | | plugin | `marketplace` (ref), `plugin_name`, `version`, `skills`, `hook_provides`, `heartbeat` | | skill | `plugin` (the containing plugin, for external skills) | +| agent | none beyond the common fields; the agent itself is in its asset files | | tool | `check {command, expect}`, `install_hint` | - Credentials: `env` and `headers` map a name to `{env: VAR}`. Any literal value @@ -72,6 +74,24 @@ Kind-specific fields: - `unless_capability`: the primitive is a fallback, active only on harnesses that lack the named capability (for example `plugin_hooks`). +### Agents + +An agent is a worker definition the harness can hand a job to: a Claude Code subagent and +a Codex custom agent. Its asset directory holds one file per harness, already rendered by +the author; Kit copies the file and never converts one format into the other. + +| Harness | Asset | Must contain | Written to | +|---|---|---|---| +| claude | `claude.md` | YAML frontmatter with `name` (the target file name) and `description` | `.claude/agents/.md` | +| codex | `codex.toml` | string `name`, `description` and `developer_instructions` | `.codex/agents/.toml` | +| cursor | – | – | a gap: no Cursor agent location is verified | + +Each harness listed in `harnesses` needs its file (`harnesses: [claude]` needs only +`claude.md`; no `harnesses` means all three, so both files). `` is the last segment of a namespaced name, so `acme/reviewer` +lands as `reviewer.md`. A missing directory or file, TOML that does not parse, a missing +Codex key, or a frontmatter `name` that differs from the file name is an `agent` problem +in `kitctl validate`. Forge does not propose agents and `kitctl vendor` does not copy them. + ## Loadouts Version 1 allows `name`, `description`, `extends`, `skills`, `mcp`, `hooks` and @@ -82,17 +102,46 @@ Version 1 allows `name`, `description`, `extends`, `skills`, `mcp`, `hooks` and | `marketplaces` | list of marketplace refs | | `mcp` | `{server-id: {primitive: mcp:NAME, enabled_tools?, disabled_tools?}}` | | `slots` | `{slot: ref}`: the declared single provider | +| `agents` | list of agent refs | | `disable` | list of refs of any kind | | `requires`, `optional` | tool refs; `doctor` checks them, and Kit never installs tools | | `setup` | `[{run: [...], when: file-exists:PATH, outputs: [...], harness: any\|claude\|codex\|cursor}]` | | `instructions` | text for the managed `CLAUDE.md` / `AGENTS.md` block, or the generated Cursor rule file `.cursor/rules/kit-.mdc` | -| `profiles` | `{name: {description, skills, mcp, hooks, plugins, marketplaces, requires, optional, setup, instructions_append}}` | +| `budget` | `{tokens: N, skills: N}`, each optional and a positive integer: the always-on listing this loadout is allowed (see below) | +| `profiles` | `{name: {description, skills, agents, mcp, hooks, plugins, marketplaces, requires, optional, setup, instructions_append, budget}}` | A profile is addressed as `loadout:profile`. Each field present in a profile replaces that field, and its entries must already be in the loadout: a profile is a subset and never adds anything. Disables and slots carry over. A slot whose provider the profile removed is reported as `unfilled`. +### Budget + +Every session lists the instruction block, each model-invocable skill and each agent +before any work starts. `budget` caps that listing: `tokens` for the estimated text +(`ceil(chars / 4)`, the method Gestalt uses), `skills` for the number of listed skills. +Each number comes from the most specific layer that sets it: a child overrides its +parent, and a profile's `budget` overrides the loadout's. + +`kitctl doctor` measures the listing per harness (`listing_cost` in its JSON) for the +active set: `--loadout` plus every loadout in the `kit.yaml` of each root it inspects +(`~/.kit/kit.yaml` when the root is home, since user and project loadouts list together). +It counts: + +- the instruction block's text; +- each listed skill as `name: description`: a warehouse skill from its `SKILL.md` + frontmatter (else the primitive's `description`), a plugin's skills from the + `SKILL.md` files of the installed Claude Code plugin (read when home is a root); +- each agent as `name: description`. + +A skill with `disable-model-invocation: true` is not listed and costs nothing; a skill the +loadout disables does not count. What doctor cannot read is named under `not_measured` +instead of guessed: plugin skills that are not installed (or are on a harness whose plugin +installs Kit cannot see) and MCP servers, whose tool lists exist only once a server runs. +When the active set is over the strictest `budget` among its loadouts, doctor adds a +`budget` warning naming the number that is over and by how much. It is never an error, so +doctor's exit code does not change. + ## Resolution rules 1. **Inheritance.** `extends` is linearized parents first, in declared order, diff --git a/src/kitctl/cli.py b/src/kitctl/cli.py index 6aa20fa..6c3e3ff 100644 --- a/src/kitctl/cli.py +++ b/src/kitctl/cli.py @@ -270,6 +270,15 @@ def _doctor(args: argparse.Namespace) -> int: else: for tool in report["tools"]: print(f"{tool['status']:8} {tool['identity']} {tool['output']}") + for harness, section in report["harnesses"].items(): + cost = section.get("listing_cost") + if cost: + print( + f"listing {harness}: {cost['tokens']} tokens, {cost['skills']} skills " + f"({cost['method']})" + ) + for item in cost["not_measured"]: + print(f" not measured: {item}") for finding in report["findings"]: print( f"{finding['severity']:7} {finding['check']}: {finding['subject']}: " diff --git a/src/kitctl/cost.py b/src/kitctl/cost.py new file mode 100644 index 0000000..a22895d --- /dev/null +++ b/src/kitctl/cost.py @@ -0,0 +1,272 @@ +"""Always-on listing cost: what a set of loadouts puts in front of the model in every session. + +Each session lists the instruction block, every model-invocable skill (`name: description`) +and every agent before any work starts. This module counts that text with a fixed estimate, +the same one Gestalt uses (tools/lib/tokens.mjs), so a budget means the same number in both. +Scripts count; nothing here asks a model, starts an MCP server, or writes a file. +""" + +from __future__ import annotations + +import math +import tomllib +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path, PurePosixPath +from typing import Any + +from kitctl.model import ( + AGENT_FILES, + BUDGET_KEYS, + KIND_DIRS, + ModelError, + Primitive, + frontmatter, + parse_primitive, +) +from kitctl.resolve import effective, resolve + +METHOD = "estimate: ceil(chars / 4)" + + +def estimate_tokens(text: str) -> int: + return math.ceil(len(text) / 4) + + +def listing_line(name: str, description: str) -> str: + """One listed skill or agent, as Gestalt counts it.""" + return f"{name}: {description}" + + +def _read(path: Path) -> str | None: + try: + return path.read_bytes().decode("utf-8") + except (OSError, UnicodeDecodeError): + return None + + +def _skill_front(path: Path) -> dict[str, Any] | None: + text = _read(path) if path.is_file() else None + return None if text is None else frontmatter(text) + + +def _hidden(front: dict[str, Any]) -> bool: + """A skill the model cannot invoke is not listed, so it costs nothing.""" + return front.get("disable-model-invocation") is True + + +def _text(value: Any) -> str: + return value if isinstance(value, str) else "" + + +@dataclass +class Listing: + tokens: int = 0 + skills: set[str] = field(default_factory=set) + agents: set[str] = field(default_factory=set) + blocks: set[tuple[str, str]] = field(default_factory=set) + not_measured: list[str] = field(default_factory=list) + + def note(self, message: str) -> None: + if message not in self.not_measured: + self.not_measured.append(message) + + def skill(self, key: str, name: str, description: str) -> None: + """Count a listed skill once per key (its native name, plugin-qualified).""" + if key not in self.skills: + self.skills.add(key) + self.tokens += estimate_tokens(listing_line(name, description)) + + def result(self) -> dict[str, Any]: + return { + "method": METHOD, + "tokens": self.tokens, + "skills": len(self.skills), + "not_measured": self.not_measured, + } + + +class _Measure: + def __init__( + self, + warehouse: Path, + harness: str, + installed: Mapping[str, str], + plugin_ids: Mapping[str, str], + ) -> None: + self.warehouse = warehouse + self.harness = harness + self.installed = installed + self.plugin_ids = plugin_ids + self.loaded: dict[str, Primitive] = {} + self.listing = Listing() + + def primitive(self, ref: str) -> Primitive | None: + if ref not in self.loaded: + try: + self.loaded[ref] = parse_primitive(self.warehouse, ref) + except ModelError: + return None + return self.loaded[ref] + + def assets(self, primitive: Primitive) -> Path: + return ( + self.warehouse + / "primitives" + / KIND_DIRS[primitive.kind] + / PurePosixPath(primitive.name) + ) + + def native_skill(self, primitive: Primitive) -> str: + return ( + primitive.harnesses.get(self.harness, {}).get("skill") + or primitive.name.rsplit("/", 1)[-1] + ) + + def skill(self, primitive: Primitive, disabled: set[str]) -> None: + name = self.native_skill(primitive) + description = _text(primitive.data.get("description")) + front = ( + None + if primitive.data.get("external") + else _skill_front(self.assets(primitive) / "SKILL.md") + ) + if front is not None: + if _hidden(front): + return + name = _text(front.get("name")) or name + description = _text(front.get("description")) or description + if name not in disabled: + self.listing.skill(name, name, description) + + def agent(self, primitive: Primitive) -> None: + if self.harness not in AGENT_FILES or primitive.data.get("external"): + return # Cursor gets a gap, not a file; an external agent is not Kit's to count + name = primitive.name.rsplit("/", 1)[-1] + if name in self.listing.agents: + return + self.listing.agents.add(name) + description = _text(primitive.data.get("description")) + text = _read(self.assets(primitive) / AGENT_FILES[self.harness]) + if text is not None: + if self.harness == "codex": + try: + data: dict[str, Any] = tomllib.loads(text) + except tomllib.TOMLDecodeError: + data = {} + else: + data = frontmatter(text) + description = _text(data.get("description")) or description + self.listing.tokens += estimate_tokens(listing_line(name, description)) + + def plugin(self, primitive: Primitive, disabled: set[str]) -> None: + native = self.plugin_ids.get(primitive.identity) + path = self.installed.get(native) if native and self.harness == "claude" else None + root = Path(path) if path else None + if root is None or not root.is_dir(): + where = ( + "not installed in Claude Code" + if self.harness == "claude" + else f"Kit cannot see {self.harness}'s installed plugins" + ) + declared = primitive.data.get("skills") or [] + count = f", declares {len(declared)}" if declared else "" + self.listing.note(f"{primitive.identity} skills: {where}{count}") + return + prefix = (native or "").split("@", 1)[0] + for manifest in sorted((root / "skills").glob("*/SKILL.md")): + front = _skill_front(manifest) + if front is None or _hidden(front): + continue + name = _text(front.get("name")) or manifest.parent.name + if name in disabled or f"{prefix}:{name}" in disabled: + continue + self.listing.skill(f"{prefix}:{name}", name, _text(front.get("description"))) + + def target(self, target: str, scope: str) -> None: + try: + plan = resolve(self.warehouse, target, self.harness, scope=scope) + merged = effective(self.warehouse, target) + except ModelError: + self.listing.note(f"{target}: does not resolve (`kitctl validate` says why)") + return + block = plan["instructions"] + if block and (block["target"], block["marker"]) not in self.listing.blocks: + self.listing.blocks.add((block["target"], block["marker"])) + self.listing.tokens += estimate_tokens(block["text"]) + disabled: set[str] = set() + for record in plan["disables"]: + primitive = self.primitive(record["identity"]) + if primitive is not None and primitive.kind == "skill": + disabled.add(self.native_skill(primitive)) + servers, tools = 0, 0 + for item in plan["primitives"]: + primitive = self.primitive(item["identity"]) + if primitive is None: + continue + if primitive.kind == "skill": + self.skill(primitive, disabled) + elif primitive.kind == "agent": + self.agent(primitive) + elif primitive.kind == "plugin": + self.plugin(primitive, disabled) + elif primitive.kind == "mcp": + servers += 1 + tools += sum( + len(entry.get("enabled_tools", [])) + for entry, _ in merged.mcp.values() + if entry["primitive"] == primitive.identity + ) + if servers: + named = f", {tools} named enabled tools" if tools else "" + self.listing.note( + f"{target}: {servers} MCP servers{named} (tool lists need a running server; " + "doctor never starts one)" + ) + + +def listing_cost( + warehouse: Path, + targets: list[tuple[str, str]], + harness: str, + installed: Mapping[str, str] | None = None, + plugin_ids: Mapping[str, str] | None = None, +) -> dict[str, Any]: + """The always-on listing of every (target, scope) together, for one harness. + + installed maps a Claude Code plugin id to its install path; plugin_ids maps a plugin + primitive to its native id on this harness. + """ + measure = _Measure(warehouse, harness, installed or {}, plugin_ids or {}) + for target, scope in targets: + measure.target(target, scope) + return measure.listing.result() + + +def strictest_budget(warehouse: Path, targets: list[str]) -> dict[str, tuple[int, str]]: + """The lowest declared number per budget key across targets, with who declared it.""" + budget: dict[str, tuple[int, str]] = {} + for target in targets: + try: + merged = effective(warehouse, target) + except ModelError: + continue + for key in BUDGET_KEYS: + if key in merged.budget and ( + key not in budget or merged.budget[key][0] < budget[key][0] + ): + budget[key] = merged.budget[key] + return budget + + +def over_budget(cost: dict[str, Any], budget: dict[str, tuple[int, str]]) -> list[str]: + """One message per budget number the listing exceeds.""" + messages = [] + for key, unit in (("tokens", "tokens"), ("skills", "listed skills")): + if key in budget and cost[key] > budget[key][0]: + limit, source = budget[key] + messages.append( + f"{cost[key]} {unit} is over the budget of {limit} (from {source}) by " + f"{cost[key] - limit}" + ) + return messages diff --git a/src/kitctl/doctor.py b/src/kitctl/doctor.py index 59e3cb6..9c25d7f 100644 --- a/src/kitctl/doctor.py +++ b/src/kitctl/doctor.py @@ -16,9 +16,11 @@ from pathlib import Path from typing import Any +from kitctl import engine +from kitctl.cost import listing_cost, over_budget, strictest_budget from kitctl.inventory import inventory from kitctl.model import DEFAULT_HARNESSES, ModelError, Primitive, discover, parse_primitive -from kitctl.resolve import resolve +from kitctl.resolve import resolve, split_target Runner = Callable[[list[str]], tuple[int, str]] CHECK_TIMEOUT = 15 @@ -211,13 +213,16 @@ def doctor( "tools": [], "findings": findings, } - if target is None or not warehouse.is_dir(): + active = _active_set(roots, target, harnesses, findings) + if (target is None and not any(active.values())) or not warehouse.is_dir(): if target is not None: findings.append(_finding("error", "warehouse", str(warehouse), "warehouse not found")) return result index = Index(warehouse) tools: dict[str, tuple[str, dict[str, Any]]] = {} for harness in harnesses: + if target is None: + break # a kit.yaml without --loadout: only the listing cost below state = observe(report, index, harness) section: dict[str, Any] = { "observed": {k: v for k, v in state["enabled"].items()}, @@ -415,4 +420,78 @@ def doctor( "mismatch": f"{line!r} does not match {expect!r}", }[status] findings.append(_finding(severity, "tool", ref, detail, tool["install_hint"])) + _listing(warehouse, report, index, active, result, saw_home) return result + + +def _active_set( + roots: list[Path], + target: str | None, + harnesses: tuple[str, ...], + findings: list[dict[str, Any]], +) -> dict[str, list[tuple[str, str]]]: + """(target, scope) pairs that list in a session, per harness: --loadout plus every loadout + in the kit.yaml of an inspected root (~/.kit/kit.yaml when the root is home).""" + active: dict[str, list[tuple[str, str]]] = { + h: [(target, "project")] if target else [] for h in harnesses + } + for root in roots: + root = root.expanduser().resolve() + if not root.is_dir(): + continue + try: + intent = engine.read_intent(root) + except (engine.EngineError, OSError) as error: + findings.append(_finding("warning", "intent", str(root), str(error))) + continue + scope = "user" if engine.is_user_scope(root) else "project" + for item in intent["loadouts"]: + if scope == "project" and target and split_target(item)[0] == split_target(target)[0]: + continue # --loadout names the one doctor checks + for harness in harnesses: + if harness in intent["harnesses"] and (item, scope) not in active[harness]: + active[harness].append((item, scope)) + return active + + +def _listing( + warehouse: Path, + report: Any, + index: Index, + active: dict[str, list[tuple[str, str]]], + result: dict[str, Any], + saw_home: bool, +) -> None: + """Listing cost per harness, and a warning when it is over the strictest budget.""" + installed: dict[str, str] = {} + for item in report["native"]["installed_plugins"]: + if item.get("path") and item["id"] not in installed: + installed[item["id"]] = str(item["path"]) + budget = strictest_budget( + warehouse, sorted({target for pairs in active.values() for target, _ in pairs}) + ) + for harness, targets in active.items(): + if not targets: + continue + plugin_ids = {ref: pid for (h, pid), ref in index.plugins.items() if h == harness} + cost = listing_cost(warehouse, targets, harness, installed, plugin_ids) + if ( + harness == "claude" + and not installed + and any("plugin:" in n for n in cost["not_measured"]) + ): + cost["not_measured"].append( + "Claude Code's installed plugins are read only when home is a root (--root ~)" + ) + result["harnesses"].setdefault(harness, {})["listing_cost"] = cost + for message in over_budget(cost, budget): + result["findings"].append( + _finding( + "warning", + "budget", + harness, + f"always-on listing: {message}", + "drop or hide (disable-model-invocation) a skill, trim the instructions, " + "or raise the budget", + ) + ) diff --git a/src/kitctl/engine.py b/src/kitctl/engine.py index 9a29fec..443abef 100644 --- a/src/kitctl/engine.py +++ b/src/kitctl/engine.py @@ -26,10 +26,11 @@ from typing import Any from ruamel.yaml import YAML +from ruamel.yaml.error import YAMLError from kitctl import __version__, formats from kitctl.formats import MISSING, FormatError, Style, detect_style -from kitctl.model import DEFAULT_HARNESSES, HARNESSES, ModelError, is_traversal +from kitctl.model import DEFAULT_HARNESSES, HARNESSES, KIND_DIRS, ModelError, is_traversal from kitctl.resolve import resolve, split_target INTENT = "kit.yaml" @@ -174,7 +175,11 @@ def read_intent(repo: Path) -> dict[str, Any]: 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 {} + try: + data = YAML(typ="safe", pure=True).load(path.read_text(encoding="utf-8")) or {} + except (YAMLError, UnicodeDecodeError) as error: + first = str(error).strip().splitlines()[0] if str(error).strip() else "invalid YAML" + raise EngineError(f"{path} is not valid YAML ({first}); fix it by hand") from error # `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: @@ -281,25 +286,55 @@ class Desired: conflicts: list[str] = field(default_factory=list) +# What a placeholder effect copies from a primitive's asset directory. "directory": the whole +# directory, which must hold the marker file; "asset": the one file named by the effect's key. +DIRECTORY_MARKERS = {"skill": "SKILL.md"} + + +def _asset_files( + warehouse: Path, effect: dict[str, Any], conflicts: list[str] +) -> list[tuple[Path, str, str]] | None: + """(source file, target, relative name) for each file a placeholder copies, or None.""" + kind, _, name = effect["source"].partition(":") + root = warehouse / "primitives" / KIND_DIRS[kind] / PurePosixPath(name) + if effect["format"] == "asset": + path = root / effect["key"] + if not root.is_dir() or not (path.is_file() or path.is_symlink()): + conflicts.append(f"{effect['source']}: no {name}/{effect['key']} in the warehouse") + return None + return [(path, effect["target"], effect["key"])] + marker = DIRECTORY_MARKERS.get(kind) + if not root.is_dir() or (marker and not (root / marker).is_file()): + conflicts.append( + f"{effect['source']}: no {kind} directory with a {marker} in the warehouse" + if marker + else f"{effect['source']}: no {kind} directory in the warehouse" + ) + return None + # Like the content hash, a nested .git (a skill cloned in place) is not an asset. + return [ + ( + path, + f"{effect['target']}{path.relative_to(root).as_posix()}", + path.relative_to(root).as_posix(), + ) + for path in sorted(p for p in root.rglob("*") if p.is_file() or p.is_symlink()) + if ".git" not in path.relative_to(root).parts + ] + + 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.""" + """A skill directory or an agent's harness file 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": + if effect["format"] not in ("directory", "asset"): 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() + for path, target, rel in _asset_files(warehouse, effect, conflicts) or []: if path.is_symlink(): conflicts.append(f"{effect['source']}: {rel} is a symlink; Kit copies files only") continue @@ -313,7 +348,7 @@ def _expand_directories( out.append( { **effect, - "target": f"{effect['target']}{rel}", + "target": target, "format": "file", "key": effect["source"], "value": text, @@ -347,6 +382,7 @@ def _user_targets() -> tuple[dict[str, str], dict[str, str]]: def desired_state(warehouse: Path, intent: dict[str, Any], user: bool = False) -> Desired: result = Desired() + whole_files: dict[str, str] = {} # target -> the key (source) that owns the whole file prefixes: dict[str, str] = {} problems: dict[str, str] = {} if user: @@ -394,6 +430,16 @@ def desired_state(warehouse: Path, intent: dict[str, Any], user: bool = False) - "owners": [owner], } ident = entry_id(entry) + if entry["format"] == "file": + # A whole file has one owner key; two primitives whose names end alike + # (acme/review and team/review) would otherwise overwrite each other. + claimed = whole_files.setdefault(entry["target"], entry["key"]) + if claimed != entry["key"]: + result.conflicts.append( + f"{entry['target']}: both {claimed} and {entry['key']} write this " + "file; rename one" + ) + continue existing = result.entries.get(ident) if existing is None: result.entries[ident] = entry @@ -481,6 +527,9 @@ def __init__(self, repo: Path, target: str, fmt: str) -> None: p.is_symlink() for p in self.path.parents if p.is_relative_to(repo) and p != repo ): self.conflict = f"{target} is a symlink; Kit will not write through links" + elif self.path.exists() and not self.path.is_file(): + # Caught here, not halfway through replacing files during apply. + self.conflict = f"{target} exists and is not a regular file" self.before = self.path.read_bytes() if self.path.is_file() else None try: text = self.before.decode("utf-8") if self.before else "" @@ -1076,10 +1125,13 @@ def eject(repo: Path) -> list[str]: if (repo / JOURNAL).exists(): raise EngineError("an earlier apply was interrupted; run `kitctl apply` to recover first") ledger = read_ledger(repo) + # A config value that runs kitctl (a hook command) stops working once Kit is gone; text + # that merely mentions kitctl (an instruction block, a skill or agent file) does not. dependent = [ entry_id(e) for e in ledger["entries"] - if "${plugin:" in _canonical(e["value"]) or "kitctl" in _canonical(e["value"]) + if "${plugin:" in _canonical(e["value"]) + or (e["format"] in ("json", "toml") and "kitctl" in _canonical(e["value"])) ] if dependent: raise EngineError( diff --git a/src/kitctl/forge.py b/src/kitctl/forge.py index 28e3c83..0a499ca 100644 --- a/src/kitctl/forge.py +++ b/src/kitctl/forge.py @@ -275,7 +275,12 @@ def load_candidates( problems.append(f"{where}.fills: bad slot {slot!r}") config = candidate.get("config") if verdict in {"adopt", "vendor"}: - if ( + if isinstance(config, dict) and config.get("kind") == "agent": + problems.append( + f"{where}.config.kind: forge does not propose agents; render the agent's " + "claude.md and codex.toml by hand (docs/model.md#agents)" + ) + elif ( not isinstance(config, dict) or config.get("kind") not in CONFIG_KINDS or not (isinstance(config.get("name"), str) and NAME_RE.match(config["name"])) diff --git a/src/kitctl/harness.py b/src/kitctl/harness.py index 3583fd5..0744b29 100644 --- a/src/kitctl/harness.py +++ b/src/kitctl/harness.py @@ -62,6 +62,17 @@ class Harness: True, "verified", "`claude mcp add --scope project` writes .mcp.json" ), "skill_dir": Fact(True, "documented", "project skills live in .claude/skills//"), + "agent_file": Fact( + True, + "documented", + "custom subagents are Markdown files with name and description frontmatter in " + ".claude/agents/.md (project) and ~/.claude/agents/.md (user)", + ), + "agent_user_file": Fact( + True, + "documented", + "user subagents live in ~/.claude/agents/.md (CLAUDE_CONFIG_DIR/agents/)", + ), "project_trust_required": Fact( False, "verified", "project settings load without Kit trust" ), @@ -105,6 +116,19 @@ class Harness: True, "verified", "[mcp_servers.] in a trusted project config is listed and enabled" ), "skill_dir": Fact(True, "documented", "repository skills live in .agents/skills//"), + "agent_file": Fact( + True, + "documented", + "project-scope .codex/agents/.toml is assumed by analogy with the user folder; " + "no run has shown Codex loading it", + ), + "agent_user_file": Fact( + True, + "verified", + "custom agents in $CODEX_HOME/agents/.toml with string name, description and " + "developer_instructions load in Codex 0.163 (checked by a sibling project's " + "installer, not in Kit's own sandbox)", + ), "project_trust_required": Fact( True, "verified", @@ -190,6 +214,11 @@ class Harness: f"project skills live in .cursor/skills// ({COOKBOOK_SOURCE} hooks/README.md: " "update-skills-on-stop.mjs maintains .cursor/skills/*/SKILL.md)", ), + "agent_file": Fact( + False, + "documented", + "no file location for Cursor custom agents is documented in the sources above", + ), "hooks_trust_required": Fact( True, "documented", diff --git a/src/kitctl/inventory.py b/src/kitctl/inventory.py index 45daabe..2a47994 100644 --- a/src/kitctl/inventory.py +++ b/src/kitctl/inventory.py @@ -4,10 +4,12 @@ import hashlib import os +import tomllib from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, TypedDict +from kitctl.model import frontmatter from kitctl.native import read_native CONFIG_PATHS = ( @@ -25,6 +27,7 @@ "kit.lock", ) SKILL_PATHS = (".claude/skills", ".agents/skills", ".codex/skills", ".cursor/skills") +AGENT_PATHS = ((".claude/agents", ".md"), (".codex/agents", ".toml")) @dataclass(frozen=True) @@ -94,6 +97,39 @@ def fail(error: OSError) -> None: return digest.hexdigest() +def _agent_name(path: Path) -> str: + """The name an agent file declares (frontmatter or TOML `name`), else its file name.""" + try: + text = path.read_bytes().decode("utf-8") + data = tomllib.loads(text) if path.suffix == ".toml" else frontmatter(text) + except (UnicodeDecodeError, tomllib.TOMLDecodeError): + return path.stem + name = data.get("name") + return name if isinstance(name, str) and name else path.stem + + +def _agents(root: Path, entries: list[Entry], warnings: list[str]) -> None: + for relative, suffix in AGENT_PATHS: + directory = root / relative + try: + if directory.is_symlink() or any( + parent.is_symlink() for parent in directory.parents if parent != root + ): + warnings.append(f"Skipped linked agent directory: {directory}") + continue + if not directory.is_dir(): + continue + for path in sorted(directory.glob(f"*{suffix}")): + if path.is_symlink(): + warnings.append(f"Skipped linked agent: {path}") + elif _safe_file(path, root): + entries.append( + Entry("agent", _agent_name(path), str(root), str(path), _file_hash(path)) + ) + except OSError: + warnings.append(f"Cannot read: {directory}") + + def inventory(roots: list[Path]) -> Report: entries: list[Entry] = [] warnings: list[str] = [] @@ -148,6 +184,7 @@ def inventory(roots: list[Path]) -> Report: warnings.append(f"Cannot read complete skill: {skill}") except OSError: warnings.append(f"Cannot read: {directory}") + _agents(root, entries, warnings) return { "schema_version": 2, "entries": [asdict(e) for e in entries], diff --git a/src/kitctl/model.py b/src/kitctl/model.py index e539da4..ed06863 100644 --- a/src/kitctl/model.py +++ b/src/kitctl/model.py @@ -12,6 +12,7 @@ import json import os import re +import tomllib from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -23,7 +24,7 @@ # What a project or brief gets when it names no harnesses; adding a harness must not # widen existing plans. DEFAULT_HARNESSES = ("claude", "codex") -KINDS = ("skill", "mcp", "hook", "plugin", "marketplace", "tool") +KINDS = ("skill", "mcp", "hook", "plugin", "marketplace", "tool", "agent") KIND_DIRS = { "skill": "skills", "mcp": "mcp", @@ -31,7 +32,12 @@ "plugin": "plugins", "marketplace": "marketplaces", "tool": "tools", + "agent": "agents", } +# An agent's asset directory holds one file per harness, already rendered by its author; +# Kit copies the file and never converts between formats. +AGENT_FILES = {"claude": "claude.md", "codex": "codex.toml"} +CODEX_AGENT_KEYS = ("name", "description", "developer_instructions") HASH_SCHEME = "kit-hash-v1" SCHEMA_VERSIONS = (1, 2) CURRENT_SCHEMA = 2 @@ -84,6 +90,7 @@ "plugin": {"marketplace", "plugin_name", "version", "skills", "hook_provides", "heartbeat"}, "marketplace": set(), "tool": {"check", "install_hint"}, + "agent": set(), } V1_PRIMITIVE = {"schema_version", "kind", "name", "description", "tags", "harnesses", "source"} HARNESS_KEYS = {"id", "skill", "manifest", "path"} @@ -107,9 +114,12 @@ "setup", "instructions", "profiles", + "agents", + "budget", } LIST_FIELDS = { "skills": "skill", + "agents": "agent", "hooks": "hook", "plugins": "plugin", "marketplaces": "marketplace", @@ -127,7 +137,10 @@ "optional", "setup", "instructions_append", + "agents", + "budget", } +BUDGET_KEYS = ("tokens", "skills") SETUP_KEYS = {"run", "when", "outputs", "harness"} MCP_ENTRY_KEYS = {"primitive", "enabled_tools", "disabled_tools"} @@ -195,6 +208,7 @@ class Loadout: description: str = "" extends: list[str] = field(default_factory=list) skills: list[str] = field(default_factory=list) + agents: list[str] = field(default_factory=list) hooks: list[str] = field(default_factory=list) plugins: list[str] = field(default_factory=list) marketplaces: list[str] = field(default_factory=list) @@ -205,6 +219,7 @@ class Loadout: optional: list[str] = field(default_factory=list) setup: list[dict[str, Any]] = field(default_factory=list) instructions: str = "" + budget: dict[str, int] = field(default_factory=dict) profiles: dict[str, Profile] = field(default_factory=dict) @@ -463,6 +478,94 @@ def _check_kind(kind: str, data: dict[str, Any], where: str, problems: list[Prob problems.append(Problem(f"{where}.install_hint", "type", "expected a string")) +def frontmatter(text: str) -> dict[str, Any]: + """The YAML frontmatter of a Markdown file; {"_error": ...} when it does not parse.""" + match = re.match(r"^---\r?\n(.*?)\r?\n---", text, re.S) + if not match: + return {} + try: + data = YAML(typ="safe", pure=True).load(match.group(1)) + except Exception: # malformed frontmatter is a fact, not a crash + return {"_error": "unparseable frontmatter"} + return data if isinstance(data, dict) else {} + + +def _read_text(path: Path) -> str | None: + try: + return path.read_bytes().decode("utf-8") + except (OSError, UnicodeDecodeError): + return None + + +def _check_agent( + name: str, + harnesses: dict[str, dict[str, str]], + assets: Path, + where: str, + problems: list[Problem], +) -> None: + """An agent's asset directory has a ready file for each harness it lists.""" + if not assets.is_dir() or assets.is_symlink(): + problems.append( + Problem(where, "agent", f"no agent directory {assets.name}/ next to the primitive") + ) + return + tail = name.rsplit("/", 1)[-1] + for harness, filename in AGENT_FILES.items(): + if harness not in harnesses: + continue + path = assets / filename + if path.is_symlink() or not path.is_file(): + problems.append( + Problem( + where, "agent", f"{harness} is listed but {assets.name}/{filename} is missing" + ) + ) + continue + text = _read_text(path) + if text is None: + problems.append(Problem(where, "agent", f"{assets.name}/{filename} is not UTF-8 text")) + continue + if harness == "codex": + try: + data = tomllib.loads(text) + except tomllib.TOMLDecodeError as error: + problems.append( + Problem(where, "agent", f"{assets.name}/{filename} is not valid TOML: {error}") + ) + continue + for key in CODEX_AGENT_KEYS: + if not isinstance(data.get(key), str) or not data[key].strip(): + problems.append( + Problem( + where, + "agent", + f"{assets.name}/{filename} needs a string {key} (Codex ignores the " + "agent without it)", + ) + ) + else: + front = frontmatter(text) + if "_error" in front or not front: + problems.append( + Problem(where, "agent", f"{assets.name}/{filename} has no YAML frontmatter") + ) + continue + if front.get("name") != tail: + problems.append( + Problem( + where, + "agent", + f"{assets.name}/{filename}: frontmatter name must be {tail!r}, the file " + "name Kit writes", + ) + ) + if not isinstance(front.get("description"), str) or not front["description"].strip(): + problems.append( + Problem(where, "agent", f"{assets.name}/{filename} needs a description") + ) + + def _canonical(value: Any) -> bytes: return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() @@ -562,6 +665,8 @@ def parse_primitive(root: Path, ref: str) -> Primitive: assets = path.with_suffix("") if kind == "skill" and assets.is_dir() and not (assets / "SKILL.md").is_file(): problems.append(Problem(where, "skill", "skill directory has no SKILL.md")) + if kind == "agent" and not data.get("external"): + _check_agent(name, harnesses, assets, where, problems) digest = content_hash(data, assets) if "hash" in data and data["hash"] != digest: problems.append(Problem(f"{where}.hash", "hash", "recorded hash does not match content")) @@ -624,6 +729,24 @@ def _parse_setup(value: Any, where: str, problems: list[Problem]) -> list[dict[s return steps +def _parse_budget(value: Any, where: str, problems: list[Problem]) -> dict[str, int]: + """budget: {tokens, skills}, each optional and a positive integer.""" + if not isinstance(value, dict): + problems.append(Problem(where, "type", "expected {tokens: N, skills: N}")) + return {} + _unknown(value, set(BUDGET_KEYS), where, problems) + budget: dict[str, int] = {} + for key in BUDGET_KEYS: + if key not in value: + continue + item = value[key] + if isinstance(item, bool) or not isinstance(item, int) or item <= 0: + problems.append(Problem(f"{where}.{key}", "type", "expected a positive integer")) + else: + budget[key] = item + return budget + + def _parse_lists( data: dict[str, Any], where: str, problems: list[Problem], fields: set[str] ) -> dict[str, list[str]]: @@ -693,6 +816,8 @@ def parse_loadout(root: Path, name: str) -> Loadout: if parsed := parse_ref(item, f"{where}.disable", problems): loadout.disable.append(parsed) loadout.setup = _parse_setup(data.get("setup", []), f"{where}.setup", problems) + if "budget" in data: + loadout.budget = _parse_budget(data["budget"], f"{where}.budget", problems) if "instructions" in data: if isinstance(data["instructions"], str): loadout.instructions = data["instructions"] @@ -716,6 +841,8 @@ def parse_loadout(root: Path, name: str) -> Loadout: fields["mcp"] = _parse_mcp_map(spec["mcp"] or {}, f"{pwhere}.mcp", problems) if "setup" in spec: fields["setup"] = _parse_setup(spec["setup"] or [], f"{pwhere}.setup", problems) + if "budget" in spec: + fields["budget"] = _parse_budget(spec["budget"], f"{pwhere}.budget", problems) for key in ("description", "instructions_append"): if key in spec: if isinstance(spec[key], str): diff --git a/src/kitctl/resolve.py b/src/kitctl/resolve.py index a0e27cb..6a97175 100644 --- a/src/kitctl/resolve.py +++ b/src/kitctl/resolve.py @@ -27,7 +27,7 @@ ) RESOLVE_FORMAT = 1 -SET_FIELDS = ("skills", "hooks", "plugins", "marketplaces") +SET_FIELDS = ("skills", "hooks", "plugins", "marketplaces", "agents") TOOL_FIELDS = ("requires", "optional") PACKAGE_RUNNERS = {"npx": ["-y"], "bunx": [], "pnpx": [], "uvx": []} # Claude-style tool names that only edit files; a PostToolUse hook limited to these maps @@ -46,6 +46,8 @@ class Merged: disable: list[tuple[str, str]] = field(default_factory=list) setup: list[tuple[dict[str, Any], str]] = field(default_factory=list) instructions: list[tuple[str, str]] = field(default_factory=list) + # Listing budget: each number from the most specific layer (or profile) that sets it. + budget: dict[str, tuple[int, str]] = field(default_factory=dict) def split_target(target: str) -> tuple[str, str | None]: @@ -134,6 +136,8 @@ def _merge(layers: list[Loadout], problems: list[Problem]) -> Merged: merged.setup.append((step, layer.name)) if layer.instructions.strip(): merged.instructions.append((layer.name, layer.instructions.strip())) + for key, number in layer.budget.items(): + merged.budget[key] = (number, layer.name) # parents come first: the child wins for ref in merged.tools["optional"].copy(): if ref in merged.tools["requires"]: del merged.tools["optional"][ref] @@ -189,6 +193,23 @@ def _apply_profile(top: Loadout, profile: str, merged: Merged, problems: list[Pr merged.setup = [(step, top.name) for step in fields["setup"]] if fields.get("instructions_append", "").strip(): merged.instructions.append((f"{top.name}:{profile}", fields["instructions_append"].strip())) + for key, number in fields.get("budget", {}).items(): + merged.budget[key] = (number, f"{top.name}:{profile}") + + +def effective(root: Path, target: str) -> Merged: + """The merged selections of target ("loadout" or "loadout:profile"), before compiling.""" + name, profile = split_target(target) + if is_traversal(name): + raise ModelError([Problem(name, "path-traversal", f"reference escapes: {name!r}")]) + layers = linearize(root, name) + problems: list[Problem] = [] + merged = _merge(layers, problems) + if profile: + _apply_profile(layers[-1], profile, merged, problems) + if problems: + raise ModelError(sorted(set(problems))) + return merged def _load(root: Path, refs: set[str], problems: list[Problem]) -> dict[str, Primitive]: @@ -726,6 +747,35 @@ def skill(self, primitive: Primitive, layer: str) -> None: if self.name == "cursor": self.unverified(primitive.identity, "skill_dir") + def agent(self, primitive: Primitive, layer: str) -> None: + """A worker agent: the author's ready file for this harness, copied whole.""" + tail = primitive.name.rsplit("/", 1)[-1] + if self.name == "cursor": + self.gap( + primitive.identity, + "agent_file", + "unsupported", + self.fact("agent_file").note, + remedy="set the agent up in Cursor by hand, or drop cursor from its harnesses", + ) + return + asset = {"claude": "claude.md", "codex": "codex.toml"}[self.name] + target = {"claude": f".claude/agents/{tail}.md", "codex": f".codex/agents/{tail}.toml"} + fact = "agent_user_file" if self.scope == "user" else "agent_file" + self.add( + target[self.name], + "asset", + asset, + primitive.content_hash, + primitive.identity, + layer, + fact, + ) + if self.name == "codex": + # Claude's agent folder is documented like its skill folder; Codex project agents + # are the one location nobody has seen loaded (docs/harness-reference.md). + self.unverified(primitive.identity, fact) + def disable(self, primitive: Primitive, layer: str, loaded: dict[str, Primitive]) -> None: subject = primitive.identity soft = f"Do not use {primitive.name} in this repository." @@ -1067,6 +1117,9 @@ def resolve( for ref, layer in merged.sets["skills"].items(): if ref in active and not loaded[ref].data.get("external"): compiler.skill(loaded[ref], layer) + for ref, layer in merged.sets["agents"].items(): + if ref in active and not loaded[ref].data.get("external"): + compiler.agent(loaded[ref], layer) for primitive, layer in external_disables: compiler.disable(primitive, layer, loaded) trust = facts.facts["project_trust_required"] diff --git a/src/kitctl/scout.py b/src/kitctl/scout.py index 07befa6..95a4e6c 100644 --- a/src/kitctl/scout.py +++ b/src/kitctl/scout.py @@ -27,6 +27,7 @@ NAME_RE, ModelError, content_hash, + frontmatter, is_traversal, ) from kitctl.native import redact_url @@ -202,15 +203,7 @@ def _evidence(root: Path, path: Path, line: int, text: str) -> dict[str, Any]: return {"file": path.relative_to(root).as_posix(), "line": line, "text": text.strip()[:160]} -def _frontmatter(text: str) -> dict[str, Any]: - match = re.match(r"^---\r?\n(.*?)\r?\n---", text, re.S) - if not match: - return {} - try: - data = YAML(typ="safe", pure=True).load(match.group(1)) - except Exception: # malformed frontmatter is a fact, not a crash - return {"_error": "unparseable frontmatter"} - return data if isinstance(data, dict) else {} +_frontmatter = frontmatter # one frontmatter reader for scout, inventory and the model def facts(root: Path) -> dict[str, Any]: @@ -427,6 +420,11 @@ def vendor( policy_file: Path | None = None, ) -> dict[str, Any]: """Copy one path from a quarantined clone into inbox/, or refuse.""" + if kind == "agent": + raise ScoutError( + "agents are not vendored: render claude.md and codex.toml yourself into " + "primitives/agents// (docs/model.md#agents)" + ) clone, report = find_scouted(scouted_id, cache) if pin and not report["sha"].startswith(pin): raise ScoutError(f"pin {pin} does not match the quarantined commit {report['sha']}") diff --git a/src/kitctl/starter.py b/src/kitctl/starter.py index c817442..9fba817 100644 --- a/src/kitctl/starter.py +++ b/src/kitctl/starter.py @@ -6,7 +6,7 @@ import subprocess from pathlib import Path -KINDS = ("skills", "mcp", "hooks", "plugins", "marketplaces", "tools") +KINDS = ("skills", "agents", "mcp", "hooks", "plugins", "marketplaces", "tools") FILES = { "README.md": """# My kit warehouse @@ -16,8 +16,8 @@ - `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. +- `primitives//`: the skills, agents, 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: diff --git a/src/kitctl/validate.py b/src/kitctl/validate.py index 5d246a3..deba911 100644 --- a/src/kitctl/validate.py +++ b/src/kitctl/validate.py @@ -20,7 +20,16 @@ def _loadout_refs(loadout: Loadout) -> list[tuple[str, str]]: refs: list[tuple[str, str]] = [] - for key in ("skills", "hooks", "plugins", "marketplaces", "requires", "optional", "disable"): + for key in ( + "skills", + "agents", + "hooks", + "plugins", + "marketplaces", + "requires", + "optional", + "disable", + ): refs += [(ref, f"{loadout.path}.{key}") for ref in getattr(loadout, key)] refs += [(e["primitive"], f"{loadout.path}.mcp.{s}") for s, e in loadout.mcp.items()] refs += [(ref, f"{loadout.path}.slots.{slot}") for slot, ref in loadout.slots.items()] diff --git a/tests/fixtures/cases/primitives/agents/bad-toml.yaml b/tests/fixtures/cases/primitives/agents/bad-toml.yaml new file mode 100644 index 0000000..415ad9e --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/bad-toml.yaml @@ -0,0 +1,5 @@ +schema_version: 2 +kind: agent +name: bad-toml +description: "Planted defect: codex.toml does not parse." +harnesses: [claude, codex] diff --git a/tests/fixtures/cases/primitives/agents/bad-toml/claude.md b/tests/fixtures/cases/primitives/agents/bad-toml/claude.md new file mode 100644 index 0000000..032580e --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/bad-toml/claude.md @@ -0,0 +1,5 @@ +--- +name: bad-toml +description: Reviews a diff. +--- +Review the diff you are given. diff --git a/tests/fixtures/cases/primitives/agents/bad-toml/codex.toml b/tests/fixtures/cases/primitives/agents/bad-toml/codex.toml new file mode 100644 index 0000000..3c1cdce --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/bad-toml/codex.toml @@ -0,0 +1,2 @@ +name = "bad-toml +description = "unterminated" diff --git a/tests/fixtures/cases/primitives/agents/no-assets.yaml b/tests/fixtures/cases/primitives/agents/no-assets.yaml new file mode 100644 index 0000000..cc84a71 --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/no-assets.yaml @@ -0,0 +1,5 @@ +schema_version: 2 +kind: agent +name: no-assets +description: "Planted defect: no asset directory." +harnesses: [claude, codex] diff --git a/tests/fixtures/cases/primitives/agents/no-codex.yaml b/tests/fixtures/cases/primitives/agents/no-codex.yaml new file mode 100644 index 0000000..a36d9a9 --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/no-codex.yaml @@ -0,0 +1,5 @@ +schema_version: 2 +kind: agent +name: no-codex +description: "Planted defect: lists codex without codex.toml." +harnesses: [claude, codex] diff --git a/tests/fixtures/cases/primitives/agents/no-codex/claude.md b/tests/fixtures/cases/primitives/agents/no-codex/claude.md new file mode 100644 index 0000000..7364440 --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/no-codex/claude.md @@ -0,0 +1,5 @@ +--- +name: no-codex +description: Reviews a diff. +--- +Review the diff you are given. diff --git a/tests/fixtures/cases/primitives/agents/no-instructions.yaml b/tests/fixtures/cases/primitives/agents/no-instructions.yaml new file mode 100644 index 0000000..0e64745 --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/no-instructions.yaml @@ -0,0 +1,5 @@ +schema_version: 2 +kind: agent +name: no-instructions +description: "Planted defect: codex.toml without developer_instructions." +harnesses: [claude, codex] diff --git a/tests/fixtures/cases/primitives/agents/no-instructions/claude.md b/tests/fixtures/cases/primitives/agents/no-instructions/claude.md new file mode 100644 index 0000000..aef3294 --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/no-instructions/claude.md @@ -0,0 +1,5 @@ +--- +name: no-instructions +description: Reviews a diff. +--- +Review the diff you are given. diff --git a/tests/fixtures/cases/primitives/agents/no-instructions/codex.toml b/tests/fixtures/cases/primitives/agents/no-instructions/codex.toml new file mode 100644 index 0000000..7a2b81b --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/no-instructions/codex.toml @@ -0,0 +1,2 @@ +name = "no-instructions" +description = "Reviews a diff." diff --git a/tests/fixtures/cases/primitives/agents/wrong-name.yaml b/tests/fixtures/cases/primitives/agents/wrong-name.yaml new file mode 100644 index 0000000..e0e8239 --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/wrong-name.yaml @@ -0,0 +1,5 @@ +schema_version: 2 +kind: agent +name: wrong-name +description: "Planted defect: claude.md frontmatter names another agent." +harnesses: [claude, codex] diff --git a/tests/fixtures/cases/primitives/agents/wrong-name/claude.md b/tests/fixtures/cases/primitives/agents/wrong-name/claude.md new file mode 100644 index 0000000..3711a6e --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/wrong-name/claude.md @@ -0,0 +1,5 @@ +--- +name: reviewer +description: Reviews a diff. +--- +Review the diff you are given. diff --git a/tests/fixtures/cases/primitives/agents/wrong-name/codex.toml b/tests/fixtures/cases/primitives/agents/wrong-name/codex.toml new file mode 100644 index 0000000..912fae7 --- /dev/null +++ b/tests/fixtures/cases/primitives/agents/wrong-name/codex.toml @@ -0,0 +1,3 @@ +name = "wrong-name" +description = "Reviews a diff." +developer_instructions = "Review the diff you are given." diff --git a/tests/test_agents.py b/tests/test_agents.py new file mode 100644 index 0000000..bd75e90 --- /dev/null +++ b/tests/test_agents.py @@ -0,0 +1,301 @@ +"""Worker agents from the warehouse: each harness's ready file copied whole, updated, removed.""" + +import shutil +from pathlib import Path + +import pytest + +from kitctl import engine +from kitctl.cli import main +from kitctl.inventory import inventory +from kitctl.model import ModelError, parse_loadout, parse_primitive +from kitctl.resolve import resolve +from kitctl.starter import init_warehouse +from kitctl.validate import validate + +CASES = Path(__file__).parent / "fixtures" / "cases" +CLAUDE = ( + b"---\nname: reviewer\ndescription: Reviews the diff it is handed.\n" + b"tools: Read, Grep\n---\nReview the diff you are given; report findings only.\n" +) +CODEX = ( + b'name = "reviewer"\ndescription = "Reviews the diff it is handed."\n' + b'developer_instructions = """\nReview the diff you are given; report findings only.\n"""\n' +) + + +def write(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +@pytest.fixture +def agent_warehouse(tmp_path: Path) -> Path: + root = tmp_path / "kits" + agents = root / "primitives" / "agents" + write( + agents / "reviewer.yaml", + b"schema_version: 2\nkind: agent\nname: reviewer\n" + b"description: Reviews the diff it is handed.\nharnesses: [claude, codex]\n", + ) + write(agents / "reviewer" / "claude.md", CLAUDE) + write(agents / "reviewer" / "codex.toml", CODEX) + write( + root / "loadouts" / "review.yaml", + b"schema_version: 2\nname: review\ndescription: Reviews.\nextends: []\n" + b"agents: [agent:reviewer]\nprofiles:\n quiet:\n agents: []\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() + write(repo / "CLAUDE.md", 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, target: str = "review") -> engine.Plan: + intent = engine.read_intent(repo) + change = engine.with_loadout(intent, target) if on else engine.without_loadout(intent, target) + return engine.make_plan(repo, warehouse, change) + + +def test_apply_copies_each_harness_file_byte_exact_and_off_removes_it( + project: Path, agent_warehouse: Path +) -> None: + baseline = files(project) + p = plan(project, agent_warehouse) + assert p.conflicts == [] + engine.apply_plan(p) + assert (project / ".claude/agents/reviewer.md").read_bytes() == CLAUDE + assert (project / ".codex/agents/reviewer.toml").read_bytes() == CODEX + entries = engine.status(project, agent_warehouse)["entries"] + assert entries and all(e["state"] == "ok" for e in entries) + engine.apply_plan(plan(project, agent_warehouse, on=False)) + assert files(project) == baseline + assert not (project / ".claude").exists() and not (project / ".codex").exists() + + +def test_a_crlf_agent_file_is_copied_as_lf(project: Path, agent_warehouse: Path) -> None: + write(agent_warehouse / "primitives/agents/reviewer/claude.md", CLAUDE.replace(b"\n", b"\r\n")) + engine.apply_plan(plan(project, agent_warehouse)) + assert (project / ".claude/agents/reviewer.md").read_bytes() == CLAUDE + assert all(e["state"] == "ok" for e in engine.status(project, agent_warehouse)["entries"]) + + +def test_a_changed_agent_in_the_warehouse_updates_the_copy( + project: Path, agent_warehouse: Path +) -> None: + engine.apply_plan(plan(project, agent_warehouse)) + write( + agent_warehouse / "primitives/agents/reviewer/codex.toml", CODEX.replace(b"only", b"ONLY") + ) + p = plan(project, agent_warehouse) + assert p.conflicts == [] + engine.apply_plan(p) + assert b"ONLY" in (project / ".codex/agents/reviewer.toml").read_bytes() + assert (project / ".claude/agents/reviewer.md").read_bytes() == CLAUDE + + +def test_a_hand_edited_copy_is_drift_not_overwritten(project: Path, agent_warehouse: Path) -> None: + engine.apply_plan(plan(project, agent_warehouse)) + target = project / ".claude/agents/reviewer.md" + write(target, CLAUDE + b"My own line.\n") + states = {e["id"]: e["state"] for e in engine.status(project, agent_warehouse)["entries"]} + assert [s for i, s in states.items() if ".claude/agents/reviewer.md" in i] == ["drift"] + off = plan(project, agent_warehouse, on=False) + assert any("drift" in c and ".claude/agents/reviewer.md" in c for c in off.conflicts) + with pytest.raises(engine.ConflictError): + engine.apply_plan(off) + assert target.read_bytes() == CLAUDE + b"My own line.\n" + + +def test_an_existing_unowned_agent_file_is_a_conflict(project: Path, agent_warehouse: Path) -> None: + write(project / ".codex/agents/reviewer.toml", b'name = "mine"\n') + p = plan(project, agent_warehouse) + assert any(".codex/agents/reviewer.toml" in c and "unmanaged" in c for c in p.conflicts) + with pytest.raises(engine.ConflictError): + engine.apply_plan(p) + assert (project / ".codex/agents/reviewer.toml").read_bytes() == b'name = "mine"\n' + + +def test_a_profile_without_agents_writes_none(project: Path, agent_warehouse: Path) -> None: + assert resolve(agent_warehouse, "review:quiet", "claude")["effects"] == [] + p = plan(project, agent_warehouse, target="review:quiet") + assert not any("agents/" in target for target in p.changes) + + +def test_user_scope_writes_under_home(agent_warehouse: Path) -> None: + home = Path.home().resolve() + baseline = files(home) + code = main(["use", "review", "--user", "--yes", "--warehouse", str(agent_warehouse)]) + assert code == 0 + assert (home / ".claude/agents/reviewer.md").read_bytes() == CLAUDE + assert (home / ".codex/agents/reviewer.toml").read_bytes() == CODEX + assert main(["off", "review", "--user", "--yes", "--warehouse", str(agent_warehouse)]) == 0 + assert files(home) == baseline + + +def test_codex_project_agents_are_unverified_and_cursor_is_a_gap(agent_warehouse: Path) -> None: + write( + agent_warehouse / "primitives/agents/reviewer.yaml", + b"schema_version: 2\nkind: agent\nname: reviewer\n" + b"description: Reviews the diff it is handed.\nharnesses: [claude, codex, cursor]\n", + ) + claude = resolve(agent_warehouse, "review", "claude") + assert [(e["target"], e["format"], e["key"]) for e in claude["effects"]] == [ + (".claude/agents/reviewer.md", "asset", "claude.md") + ] + assert not any(g["capability"].startswith("agent") for g in claude["gaps"]) + codex = resolve(agent_warehouse, "review", "codex") + assert [e["target"] for e in codex["effects"]] == [".codex/agents/reviewer.toml"] + assert [(g["capability"], g["severity"]) for g in codex["gaps"]] == [ + ("agent_file", "unverified"), + ("project_trust", "trust-required"), + ] + user = resolve(agent_warehouse, "review", "codex", scope="user") + assert not any(g["capability"].startswith("agent") for g in user["gaps"]) + cursor = resolve(agent_warehouse, "review", "cursor") + assert cursor["effects"] == [] + gap = next(g for g in cursor["gaps"] if g["capability"] == "agent_file") + assert gap["severity"] == "unsupported" and "by hand" in gap["remedy"] + + +def test_a_namespaced_agent_lands_under_its_last_segment(agent_warehouse: Path) -> None: + agents = agent_warehouse / "primitives/agents" + write( + agents / "acme" / "reviewer.yaml", + b"schema_version: 2\nkind: agent\nname: acme/reviewer\ndescription: Acme's.\n" + b"harnesses: [claude]\n", + ) + write(agents / "acme" / "reviewer" / "claude.md", CLAUDE) + write( + agent_warehouse / "loadouts/acme.yaml", + b"schema_version: 2\nname: acme\nagents: [agent:acme/reviewer]\n", + ) + targets = [e["target"] for e in resolve(agent_warehouse, "acme", "claude")["effects"]] + assert targets == [".claude/agents/reviewer.md"] + assert validate(agent_warehouse)["problems"] == [] + + +@pytest.mark.parametrize( + ("name", "message"), + [ + ("no-assets", "no agent directory no-assets/"), + ("no-codex", "codex is listed but no-codex/codex.toml is missing"), + ("bad-toml", "bad-toml/codex.toml is not valid TOML"), + ("no-instructions", "needs a string developer_instructions"), + ("wrong-name", "frontmatter name must be 'wrong-name'"), + ], +) +def test_broken_agents_are_validation_problems(name: str, message: str) -> None: + with pytest.raises(ModelError) as error: + parse_primitive(CASES, f"agent:{name}") + assert any(p.code == "agent" and message in p.message for p in error.value.problems) + report = validate(CASES) + assert any( + p["code"] == "agent" and p["path"] == f"primitives/agents/{name}.yaml" + for p in report["problems"] + ) + + +def test_a_missing_harness_file_at_apply_time_is_a_conflict( + project: Path, agent_warehouse: Path +) -> None: + (agent_warehouse / "primitives/agents/reviewer/codex.toml").unlink() + with pytest.raises(engine.EngineError): + plan(project, agent_warehouse) # validation catches it before any plan exists + + +def test_loadout_agents_must_be_agent_references(agent_warehouse: Path) -> None: + write( + agent_warehouse / "loadouts/wrong.yaml", + b"schema_version: 2\nname: wrong\nagents: [skill:reviewer]\n", + ) + with pytest.raises(ModelError) as error: + parse_loadout(agent_warehouse, "wrong") + assert {p.code for p in error.value.problems} == {"reference"} + + +def test_inventory_lists_agent_files(sandbox: Path) -> None: + write(sandbox / ".claude/agents/reviewer.md", CLAUDE) + write(sandbox / ".codex/agents/reviewer.toml", CODEX) + write(sandbox / ".codex/agents/broken.toml", b"name = \n") + agents = sorted( + (e["name"], Path(e["path"]).name) + for e in inventory([sandbox])["entries"] + if e["kind"] == "agent" + ) + assert agents == [ + ("broken", "broken.toml"), + ("reviewer", "reviewer.md"), + ("reviewer", "reviewer.toml"), + ] + + +def test_init_makes_an_agents_folder(tmp_path: Path) -> None: + init_warehouse(tmp_path / "kits", git=False) + assert (tmp_path / "kits/primitives/agents/.gitkeep").is_file() + + +def test_agents_are_not_copied_from_a_missing_directory( + project: Path, agent_warehouse: Path +) -> None: + """The engine refuses on its own, even if a primitive slipped past validation.""" + conflicts: list[str] = [] + effect = { + "target": ".claude/agents/reviewer.md", + "format": "asset", + "key": "claude.md", + "value": "hash", + "source": "agent:reviewer", + } + shutil.rmtree(agent_warehouse / "primitives/agents/reviewer") + assert engine._expand_directories(agent_warehouse, [effect], conflicts) == [] + assert conflicts == ["agent:reviewer: no reviewer/claude.md in the warehouse"] + + +def test_two_agents_with_the_same_last_segment_conflict( + project: Path, agent_warehouse: Path +) -> None: + agents = agent_warehouse / "primitives/agents" + write( + agents / "acme" / "reviewer.yaml", + b"schema_version: 2\nkind: agent\nname: acme/reviewer\ndescription: Acme's.\n" + b"harnesses: [claude]\n", + ) + write(agents / "acme" / "reviewer" / "claude.md", CLAUDE.replace(b"diff", b"change")) + write( + agent_warehouse / "loadouts/both.yaml", + b"schema_version: 2\nname: both\nagents: [agent:reviewer, agent:acme/reviewer]\n", + ) + p = plan(project, agent_warehouse, target="both") + assert any( + ".claude/agents/reviewer.md: both" in c and "agent:acme/reviewer" in c for c in p.conflicts + ) + with pytest.raises(engine.ConflictError): + engine.apply_plan(p) + assert not (project / ".claude/agents").exists() + + +def test_a_folder_where_an_agent_file_goes_is_a_conflict_before_any_write( + project: Path, agent_warehouse: Path +) -> None: + (project / ".claude/agents/reviewer.md").mkdir(parents=True) + baseline = files(project) + p = plan(project, agent_warehouse) + assert ".claude/agents/reviewer.md exists and is not a regular file" in p.conflicts + with pytest.raises(engine.ConflictError): + engine.apply_plan(p) + assert files(project) == baseline and not (project / ".kit/journal.json").exists() diff --git a/tests/test_cost.py b/tests/test_cost.py new file mode 100644 index 0000000..e6e31af --- /dev/null +++ b/tests/test_cost.py @@ -0,0 +1,250 @@ +"""Always-on listing cost in doctor, and the loadout budget that holds it.""" + +import json +from pathlib import Path + +import pytest + +from kitctl.cli import main +from kitctl.cost import METHOD, estimate_tokens, listing_cost, listing_line, strictest_budget +from kitctl.doctor import doctor +from kitctl.model import ModelError, parse_loadout +from kitctl.resolve import effective, resolve + +INSTRUCTIONS = "Work in small steps." +SHOWN = b"---\nname: shown\ndescription: Shows the plan before acting.\n---\nBody.\n" +HIDDEN = ( + b"---\nname: hidden\ndescription: Runs only by name.\ndisable-model-invocation: true\n" + b"---\nBody.\n" +) +AGENT = b"---\nname: reviewer\ndescription: Reviews diffs.\n---\nReview.\n" +CODEX_AGENT = ( + b'name = "reviewer"\ndescription = "Reviews diffs for Codex."\n' + b'developer_instructions = "Review."\n' +) + + +def write(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + + +def no_tools(command: list[str]) -> tuple[int, str]: + return 0, "" + + +@pytest.fixture +def warehouse(tmp_path: Path) -> Path: + root = tmp_path / "kits" + p = root / "primitives" + write( + p / "skills/shown.yaml", + b"schema_version: 2\nkind: skill\nname: shown\ndescription: Old words.\n", + ) + write(p / "skills/shown/SKILL.md", SHOWN) + write(p / "skills/hidden.yaml", b"schema_version: 2\nkind: skill\nname: hidden\n") + write(p / "skills/hidden/SKILL.md", HIDDEN) + write( + p / "skills/plain.yaml", + b"schema_version: 2\nkind: skill\nname: plain\ndescription: No folder; listed as is.\n" + b"external: true\n", + ) + write( + p / "agents/reviewer.yaml", + b"schema_version: 2\nkind: agent\nname: reviewer\ndescription: Reviews.\n" + b"harnesses: [claude, codex]\n", + ) + write(p / "agents/reviewer/claude.md", AGENT) + write(p / "agents/reviewer/codex.toml", CODEX_AGENT) + write( + p / "plugins/tools.yaml", + b"schema_version: 2\nkind: plugin\nname: tools\ndescription: Plugin.\nexternal: true\n" + b"skills: [lint, fmt, secret]\nharnesses:\n claude: { id: tools@market }\n" + b" codex: { id: tools@market }\n", + ) + write( + p / "skills/tools-secret.yaml", + b"schema_version: 2\nkind: skill\nname: tools-secret\nexternal: true\n" + b"plugin: plugin:tools\nharnesses:\n claude: { skill: 'tools:secret' }\n", + ) + write( + p / "mcp/docs.yaml", + b"schema_version: 2\nkind: mcp\nname: docs\ntransport: http\nurl: https://docs.example\n", + ) + write( + root / "loadouts/base.yaml", + b"schema_version: 2\nname: base\nskills: [skill:shown, skill:hidden, skill:plain]\n" + b"agents: [agent:reviewer]\nmcp:\n docs: { primitive: mcp:docs, enabled_tools: [a, b] }\n" + + f"instructions: {INSTRUCTIONS}\n".encode() + + b"budget: { tokens: 1000, skills: 1 }\n" + b"profiles:\n roomy:\n budget: { skills: 5 }\n", + ) + write( + root / "loadouts/child.yaml", + b"schema_version: 2\nname: child\nextends: [base]\nplugins: [plugin:tools]\n" + b"disable: [skill:plain, skill:tools-secret]\nbudget: { skills: 4 }\n", + ) + write(root / "loadouts/extra.yaml", b"schema_version: 2\nname: extra\ninstructions: More.\n") + return root + + +def tokens(block: str, *lines: tuple[str, str]) -> int: + return estimate_tokens(block) + sum( + estimate_tokens(listing_line(name, text)) for name, text in lines + ) + + +def block(warehouse: Path, target: str, harness: str) -> str: + text = resolve(warehouse, target, harness)["instructions"]["text"] + assert isinstance(text, str) and INSTRUCTIONS in text + return text + + +def test_estimate_is_ceil_chars_over_four() -> None: + assert [estimate_tokens(t) for t in ("", "abcd", "abcde", "é" * 8)] == [0, 1, 2, 2] + assert METHOD == "estimate: ceil(chars / 4)" + + +def test_hidden_skills_cost_nothing_and_skill_md_wins(warehouse: Path) -> None: + cost = listing_cost(warehouse, [("base", "project")], "claude") + assert cost["skills"] == 2 # shown and plain; hidden is not listed + assert cost["tokens"] == tokens( + INSTRUCTIONS, + ("shown", "Shows the plan before acting."), + ("plain", "No folder; listed as is."), + ("reviewer", "Reviews diffs."), + ) + assert any("1 MCP servers, 2 named enabled tools" in n for n in cost["not_measured"]) + codex = listing_cost(warehouse, [("base", "project")], "codex") + assert codex["tokens"] == tokens( + INSTRUCTIONS, + ("shown", "Shows the plan before acting."), + ("plain", "No folder; listed as is."), + ("reviewer", "Reviews diffs for Codex."), + ) + # Cursor's block is the whole rule file; the agent is a gap there, so it is not listed. + cursor = listing_cost(warehouse, [("base", "project")], "cursor") + assert cursor["tokens"] == tokens( + block(warehouse, "base", "cursor"), + ("shown", "Shows the plan before acting."), + ("plain", "No folder; listed as is."), + ) + + +def test_disabled_and_plugin_skills(warehouse: Path, tmp_path: Path) -> None: + plugin = tmp_path / "installed/tools" + write(plugin / "skills/lint/SKILL.md", b"---\nname: lint\ndescription: Lints.\n---\n") + write(plugin / "skills/fmt/SKILL.md", b"---\nname: fmt\ndisable-model-invocation: true\n---\n") + write(plugin / "skills/secret/SKILL.md", b"---\nname: secret\ndescription: Disabled.\n---\n") + installed = {"tools@market": str(plugin)} + ids = {"plugin:tools": "tools@market"} + cost = listing_cost(warehouse, [("child", "project")], "claude", installed, ids) + # plain is disabled by child; fmt is hidden; secret is disabled by its native name. + assert cost["skills"] == 2 + assert cost["tokens"] == tokens( + block(warehouse, "child", "claude"), # with the soft rules for the disables + ("shown", "Shows the plan before acting."), + ("lint", "Lints."), + ("reviewer", "Reviews diffs."), + ) + missing = listing_cost(warehouse, [("child", "project")], "claude", {}, ids) + assert ( + "plugin:tools skills: not installed in Claude Code, declares 3" in missing["not_measured"] + ) + codex = listing_cost(warehouse, [("child", "project")], "codex", installed, ids) + assert any("Kit cannot see codex's installed plugins" in n for n in codex["not_measured"]) + + +def test_budget_merges_child_and_profile_over_parent(warehouse: Path) -> None: + assert effective(warehouse, "base").budget == {"tokens": (1000, "base"), "skills": (1, "base")} + assert effective(warehouse, "child").budget == { + "tokens": (1000, "base"), + "skills": (4, "child"), + } + assert effective(warehouse, "base:roomy").budget["skills"] == (5, "base:roomy") + assert strictest_budget(warehouse, ["child", "base:roomy"]) == { + "tokens": (1000, "base"), + "skills": (4, "child"), + } + # Budget is a loadout setting, not an effect: the resolved plan does not change. + assert "budget" not in resolve(warehouse, "base", "claude") + + +def test_doctor_warns_over_budget_and_keeps_its_exit_code( + warehouse: Path, sandbox: Path, capsys: pytest.CaptureFixture[str] +) -> None: + report = doctor(warehouse, [sandbox], "base", ("claude",), runner=no_tools) + section = report["harnesses"]["claude"]["listing_cost"] + assert section["method"] == METHOD and section["skills"] == 2 + budget = [f for f in report["findings"] if f["check"] == "budget"] + assert [(f["severity"], f["subject"]) for f in budget] == [("warning", "claude")] + assert "2 listed skills is over the budget of 1 (from base) by 1" in budget[0]["message"] + roomy = doctor(warehouse, [sandbox], "base:roomy", ("claude",), runner=no_tools) + assert not [f for f in roomy["findings"] if f["check"] == "budget"] + args = ["doctor", "--loadout", "base", "--warehouse", str(warehouse), "--root", str(sandbox)] + assert main([*args, "--harness", "claude"]) == 0 + out = capsys.readouterr().out + assert "listing claude: " in out and "warning budget: claude" in out + + +def test_doctor_counts_the_repos_kit_yaml_loadouts(warehouse: Path, sandbox: Path) -> None: + alone = doctor(warehouse, [sandbox], "base", ("claude",), runner=no_tools) + write( + sandbox / "kit.yaml", b"schema_version: 1\nloadouts: [extra, base]\nharnesses: [claude]\n" + ) + both = doctor(warehouse, [sandbox], "base", ("claude",), runner=no_tools) + more = both["harnesses"]["claude"]["listing_cost"]["tokens"] + assert more == alone["harnesses"]["claude"]["listing_cost"]["tokens"] + estimate_tokens("More.") + # Without --loadout, a Kit-managed repo still gets its listing cost (and nothing else). + managed = doctor(warehouse, [sandbox], None, ("claude", "codex"), runner=no_tools) + assert managed["harnesses"]["claude"]["listing_cost"]["tokens"] == more + assert "codex" not in managed["harnesses"] # kit.yaml names only claude + assert [f["check"] for f in managed["findings"]] == ["budget"] + + +def test_doctor_reads_installed_plugin_skills_from_home(warehouse: Path, sandbox: Path) -> None: + home = Path.home() + plugin = home / ".claude/plugins/cache/tools" + write(plugin / "skills/lint/SKILL.md", b"---\nname: lint\ndescription: Lints.\n---\n") + write( + home / ".claude/plugins/installed_plugins.json", + json.dumps( + { + "version": 2, + "plugins": {"tools@market": [{"scope": "user", "installPath": str(plugin)}]}, + } + ).encode(), + ) + without = doctor(warehouse, [sandbox], "child", ("claude",), runner=no_tools) + notes = without["harnesses"]["claude"]["listing_cost"]["not_measured"] + assert any("--root ~" in n for n in notes) + report = doctor(warehouse, [home, sandbox], "child", ("claude",), runner=no_tools) + cost = report["harnesses"]["claude"]["listing_cost"] + assert cost["skills"] == 2 and not any("plugin:tools" in n for n in cost["not_measured"]) + + +@pytest.mark.parametrize( + "budget", + ["{ tokens: true }", "{ tokens: 0 }", "{ skills: -1 }", "{ tokens: '9' }", "{ words: 1 }", "7"], +) +def test_invalid_budget_is_rejected(warehouse: Path, budget: str) -> None: + write( + warehouse / "loadouts/bad.yaml", + f"schema_version: 2\nname: bad\nbudget: {budget}\n".encode(), + ) + with pytest.raises(ModelError): + parse_loadout(warehouse, "bad") + write( + warehouse / "loadouts/bad.yaml", + f"schema_version: 2\nname: bad\nprofiles:\n p:\n budget: {budget}\n".encode(), + ) + with pytest.raises(ModelError): + parse_loadout(warehouse, "bad") + + +def test_doctor_reports_a_malformed_kit_yaml_and_goes_on(warehouse: Path, sandbox: Path) -> None: + write(sandbox / "kit.yaml", b"loadouts: [\n") + report = doctor(warehouse, [sandbox], "base", ("claude",), runner=no_tools) + intent = [f for f in report["findings"] if f["check"] == "intent"] + assert [f["severity"] for f in intent] == ["warning"] + assert report["harnesses"]["claude"]["listing_cost"]["skills"] == 2 diff --git a/tests/test_engine.py b/tests/test_engine.py index 0b183de..6765885 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -462,3 +462,27 @@ def test_ledger_checks_survive_python_optimize(repo: Path) -> None: ) done = subprocess.run([sys.executable, "-O", "-c", code, str(repo)], check=False) assert done.returncode == 0 + + +def test_eject_keeps_a_block_that_only_mentions_kitctl(repo: Path, tmp_path: Path) -> None: + """The starter's instructions name kitctl; that is text, not a dependency on Kit.""" + from kitctl.starter import init_warehouse + + warehouse = tmp_path / "kits" + init_warehouse(warehouse, git=False) + use(repo, "starter", warehouse) + assert "kitctl status" in (repo / "CLAUDE.md").read_text() + engine.eject(repo) + text = (repo / "CLAUDE.md").read_text() + assert "kitctl status" in text and "kit:begin" not in text + assert not (repo / "kit.lock").exists() and not (repo / ".kit").exists() + + +def test_a_malformed_kit_yaml_is_an_error_not_a_crash( + repo: Path, capsys: pytest.CaptureFixture[str] +) -> None: + (repo / "kit.yaml").write_bytes(b"loadouts: [\n") + with pytest.raises(engine.EngineError, match="not valid YAML"): + engine.read_intent(repo) + assert main(["status", "--repo", str(repo), "--warehouse", str(WAREHOUSE)]) != 0 + assert "not valid YAML" in capsys.readouterr().err diff --git a/tests/test_forge.py b/tests/test_forge.py index 26a721b..c17accc 100644 --- a/tests/test_forge.py +++ b/tests/test_forge.py @@ -11,7 +11,7 @@ from kitctl.forge import ForgeError, load_candidates, new_brief, propose, verify from kitctl.model import load_yaml from kitctl.resolve import resolve -from kitctl.scout import scout, vendor +from kitctl.scout import ScoutError, scout, vendor def write_yaml(path: Path, text: str) -> None: @@ -140,6 +140,17 @@ def test_uncited_and_invented_claims_are_rejected(forged: dict[str, Any]) -> Non load_candidates(warehouse, "copy-kit", cache) +def test_agents_are_neither_forged_nor_vendored(forged: dict[str, Any]) -> None: + warehouse, cache = forged["warehouse"], forged["cache"] + (warehouse / "forge/copy-kit/candidates.yaml").write_text( + forged["text"].replace("kind: skill, name: tidy-copy", "kind: agent, name: tidy-copy") + ) + with pytest.raises(ForgeError, match="forge does not propose agents"): + load_candidates(warehouse, "copy-kit", cache) + with pytest.raises(ScoutError, match="agents are not vendored"): + vendor("any", "agents/x.md", warehouse, cache, kind="agent") + + def test_two_providers_for_a_required_slot_is_an_error(forged: dict[str, Any]) -> None: warehouse, cache = forged["warehouse"], forged["cache"] text = forged["text"].replace("fills: [copy.metrics]", "fills: [copy.checker]") diff --git a/tests/test_resolve.py b/tests/test_resolve.py index 94808e5..a7807fc 100644 --- a/tests/test_resolve.py +++ b/tests/test_resolve.py @@ -311,6 +311,7 @@ def test_validate_reports_every_broken_case(cases: Path) -> None: "path-traversal", "credential-literal", "unknown-field", + "agent", } <= found diff --git a/tests/test_skills.py b/tests/test_skills.py index ef262a3..bf3a499 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -99,3 +99,16 @@ def test_a_skill_without_skill_md_or_with_binary_assets_is_a_conflict( 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) + + +def test_a_git_folder_inside_a_skill_is_not_copied(project: Path, skill_warehouse: Path) -> None: + """A skill cloned in place keeps its .git; the hash ignores it, and so does the copy.""" + git = skill_warehouse / "primitives" / "skills" / "hello" / ".git" + (git / "objects").mkdir(parents=True) + (git / "objects" / "ab").write_bytes(b"\x78\x9c\xff\x00") + (git / "HEAD").write_bytes(b"ref: refs/heads/main\n") + p = plan(project, skill_warehouse) + assert p.conflicts == [] + engine.apply_plan(p) + assert (project / ".claude/skills/hello/SKILL.md").is_file() + assert not (project / ".claude/skills/hello/.git").exists()