Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.yaml` plus a
`<name>/` 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/<name>.md` and `codex.toml` to `.codex/agents/<name>.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
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down
13 changes: 11 additions & 2 deletions docs/apply.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,14 @@ original bytes like it does in a project.

| Project target | User target |
|---|---|
| `.claude/settings.json`, `.claude/skills/<n>/` | `~/.claude/…` (or `$CLAUDE_CONFIG_DIR/…`) |
| `.claude/settings.json`, `.claude/skills/<n>/`, `.claude/agents/<n>.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/<n>.toml` | `~/.codex/…` (or `$CODEX_HOME/…`) |
| `AGENTS.md` block | `~/.codex/AGENTS.md` |
| `.agents/skills/<n>/`, `.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-<loadout>.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
Expand All @@ -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/<name>.md` and `codex.toml` to `.codex/agents/<name>.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
Expand All @@ -109,6 +116,8 @@ Every write takes `--dry-run`. Without a terminal and without `--yes`, nothing i
| Cursor | `.cursor/mcp.json` | `mcpServers.<id>`, env and headers as `${env:NAME}` |
| Cursor | `.cursor/hooks.json` | `version`, and `hooks.<event>` 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/<n>/`, `.agents/skills/<n>/`, `.cursor/skills/<n>/` | each skill asset, a whole owned file |
| Claude, Codex | `.claude/agents/<n>.md`, `.codex/agents/<n>.toml` | the agent's ready file for that harness, a whole owned file |
| Claude, Codex | `CLAUDE.md` / `AGENTS.md` | `<!-- kit:begin NAME -->` … `<!-- kit:end NAME -->` |

Every Cursor fact is documented only, never verified (see
Expand Down
12 changes: 12 additions & 0 deletions docs/harness-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.md`, `~/.claude/agents/<name>.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/<name>.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/<name>.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
Expand Down
55 changes: 52 additions & 3 deletions docs/model.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ 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.

| Kind | File | Assets |
|---|---|---|
| skill | `primitives/skills/<name>.yaml` | `primitives/skills/<name>/` (needs `SKILL.md`) |
| agent | `primitives/agents/<name>.yaml` | `primitives/agents/<name>/` (`claude.md`, `codex.toml`) |
| mcp | `primitives/mcp/<name>.yaml` | – |
| hook | `primitives/hooks/<name>.yaml` | optional `<name>/` |
| plugin | `primitives/plugins/<name>.yaml` | optional `<name>/` |
Expand Down Expand Up @@ -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
Expand All @@ -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/<name>.md` |
| codex | `codex.toml` | string `name`, `description` and `developer_instructions` | `.codex/agents/<name>.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). `<name>` 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
Expand All @@ -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-<loadout>.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,
Expand Down
9 changes: 9 additions & 0 deletions src/kitctl/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}: "
Expand Down
Loading
Loading