Metadata-Version: 2.5
Name: pkgskills
Version: 0.6.3
Summary: Ship prompts inside a CLI package and install thin, version-stamped stubs where the harness reads them.
Project-URL: repository, https://github.com/gitronald/pkgskills
Author-email: gitronald <gitronald@users.noreply.github.com>
License-Expression: MIT
License-File: LICENSE
Requires-Python: >=3.11
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: typer
Description-Content-Type: text/markdown

# pkgskills

Ship prompts inside a CLI package and install thin, version-stamped stubs
where the harness reads them.

A *host* is a Python package that bundles skills, rules, and subagent
definitions as package data for Claude Code and Codex. `pkgskills` gives that
package three things:

- a `skill` command that prints a bundled prompt on demand, so the text an
  agent reads always comes from the installed version and there is no copy to
  go stale;
- an `install` command that materializes the files the harness must read off
  disk, each stamped in its frontmatter with the host and `pkgskills` versions
  and the install mode;
- an `install --check` that tells a current file from a drifted, missing, or
  foreign one and exits non-zero unless everything is `ok`.

Skills are installed as *stubs*: the frontmatter the harness needs to know
when to fire, plus an instruction to run `<cli> skill <name>` and follow the
output.
Rules and agents are installed as *copies*, because the harness reads their
full text with no model in the loop. Both carry the same stamp and the same
drift check. By default, every supported harness receives each artifact kind:

| Harness | Skills | Rules | Agents |
|---|---|---|---|
| Claude Code | `.claude/skills/<name>/SKILL.md` | `.claude/rules/<name>.md` | `.claude/agents/<name>.md` |
| Codex | `.agents/skills/<name>/SKILL.md` | Not installed | Not installed |

These paths are relative to `$HOME` for global mode or the repository root
for local mode. Codex discovers repo skills in `.agents/skills` from the
working directory up to the repository root, and user skills in
`~/.agents/skills`. It loads global and local skills with the same name as
separate entries; they are neither merged nor shadowed. Codex custom agents
use TOML rather than these Markdown definitions, and its `.rules` files are
execution policy rather than prompt rules, so neither kind is installed.
`Host.harnesses` defaults to `(CLAUDE_CODE, CODEX)`; set it explicitly to
restrict the target tools. An artifact installs only where its kind is
supported, and a declaration no harness supports is rejected.

Reference *documents* are the fourth thing a host declares and
the one that is never installed at all: they are printed by `<cli> doc <name>`,
which is how a body defers detail to a sidecar it can no longer reach by path.

## Install

```bash
uv add pkgskills
```

Python 3.11 or later. The runtime dependencies are typer and PyYAML.

## Declare a host

Put the prompts inside the package (hatchling ships `.md` files under a
package directory by default) and declare them once:

```python
# yourtool/cli.py
import typer

from pkgskills import Agent, Doc, Host, Rule, Skill, register

HOST = Host(
    dist="yourtool",  # distribution name, for the version lookup
    cli="yourtool",  # bare command; local mode prefixes `uv run`
    prompts="yourtool.prompts",  # package holding the prompt files
    artifacts=(
        Skill(
            name="yourtool",
            sources=("skills/add/SKILL.md", "skills/close/SKILL.md"),
        ),
        Rule(name="yourtool", source="rules/yourtool.md", render_cli=True),
        Agent(name="yourtool-reviewer", source="agents/reviewer.md"),
    ),
    docs=(Doc(name="add/fields", source="skills/add/references/fields.md"),),
)

app = typer.Typer()
register(app, HOST)  # adds skill, install, doc, rule, agent
```

Each skill source is a spec-conformant skill directory — `<name>/SKILL.md`,
holding the skill's `references/` and `scripts/` too — so the `prompts/` tree
passes a skills linter as it stands. The `skills/` group above is a
convenience, not a requirement: `pkgskills` reads only the last two components
of the path, so a host that ships nothing but skills can drop it and write
`sources=("add/SKILL.md",)`. A flat `skills/add.md` still installs and may be
mixed in, but `assert_spec_conformant(HOST)` reports it, along with anything else
misfiled, naming the rule and the fix — see
[docs/source-layout.md](docs/source-layout.md).

A skill with one source lifts that file's frontmatter into the stub byte for
byte — including a `metadata.version` the source declares as the skill's own —
and adds the [stamp](#the-stamp-and-the-check) after it (see
[docs/frontmatter.md](docs/frontmatter.md)). Its body is addressed by the
*skill's* name — `<cli> skill use-solo` — so the source need not be named
after it. A skill with several sources becomes a dispatcher: each source's
name — its directory, or its stem for a flat file — is a subcommand, and the
stub tells the agent to run `<cli> skill <subcommand>`. Its stub lists the subcommands
in a table, with each source's `description` and its `metadata.version`, or an empty
cell for a source that declares none. The stub's `metadata` holds only the stamp.
The two namespaces share one argument, so a dispatcher subcommand may not collide
with another skill's name; `Host` rejects that at construction, as it rejects a
`Skill.name` outside the spec's grammar.

A body must not point at a file by a path relative to the stub: after
`install` the stub is alone in its directory and there is nothing there to
read. Ship the file as a `Doc` and refer to it as `{cli} doc <name>` — see
[Documents](#documents).

Set `render_cli=True` on an artifact or doc whose body uses the `{cli}`
placeholder; it is rendered as `yourtool` for a global install and
`uv run yourtool` for a local one, so printed commands run as written. When
every body a host ships uses the token, set `render_cli=True` on the `Host`
instead and leave the declarations alone; an explicit flag on a declaration
still wins, so one body can opt back out.

Register the host under the `pkgskills.hosts` entry-point group and the
`pkgskills` script can find it:

```toml
[project.entry-points."pkgskills.hosts"]
yourtool = "yourtool.cli:HOST"
```

## The commands a host gains

| Command | Does |
|---|---|
| `yourtool skill [NAME] [--list]` | Print a skill body, frontmatter stripped. `NAME` is a skill's name, or a dispatcher's subcommand; it is optional when the host ships exactly one body. |
| `yourtool doc [NAME] [--list]` | Print a reference document (only when the host ships docs). |
| `yourtool rule [NAME] [--list]` | Print a rule (only when the host ships rules). |
| `yourtool agent [NAME] [--list]` | Print an agent definition (only when the host ships agents). |
| `yourtool install` | Write every artifact for the host's default mode — under `~/.claude/` and `~/.agents/` unless the host restricts its `modes`. |
| `yourtool install --local` / `--global` | Write them under the enclosing repository, or under `~/.claude/` and `~/.agents/`. Naming a mode the host does not declare is an error. |
| `yourtool install --check` | Report `ok`, `drifted`, `stale`, `missing`, or `foreign` per file (and `unreadable` per declared line); exit 1 unless all ok. |
| `yourtool install --force` | Replace files the host did not generate, and rewrite drifted lines. |
| `yourtool permissions [--level L] [--global] [--apply] [--codex]` | Print or apply an automation-level allow-rule profile; `--codex` prints a Codex rules fragment and refuses `--apply` (only when the host declares one). |

The enclosing repository is the nearest ancestor of the working directory
holding `.git`, `.claude/`, or `.codex/`, so a local install from a subdirectory
still lands where the harness loads from. Home's own `.claude/` and `.codex/` do
not count, since that is where a global install lives. `~/.git` does, so a
dotfiles repository keeps its root.

### Your own commands

A command you write beside these does not inherit that walk. One that reaches
for `Path.cwd()` writes under whatever subdirectory it ran in, while
`install --local` on the same CLI writes at the root. `pkgskills` cannot enforce
this from `Host` or `register`, so resolve the root the way the mounted commands
do:

```python
from pkgskills import find_repo_root


@app.command()
def index() -> None:
    root = find_repo_root(harnesses=HOST.harnesses)
    ...
```

Pass `harnesses`. Without it only `.git` marks a root, so in a repository whose
subdirectory holds `.claude/` or `.codex/`, the two halves of the CLI pick
different directories.

The walk always returns a directory, and outside a repository that directory is
rarely the one you want. With no marker at all it returns where it started,
including under a home with a `~/.claude` or `~/.codex`, which the walk does not
stop at. A command that should not write outside a repository checks for one:

```python
root = find_repo_root(harnesses=HOST.harnesses)
if not any((d / ".git").exists() for d in (root, *root.parents)):
    raise SystemExit("run this inside a git repository")
```

## Modes

**Global** (default) puts one copy under `$HOME` that serves every repository;
the CLI is on `PATH` and invoked bare. **Local** puts the copy under the
repository root and invokes the CLI through `uv run`. The relative layout is
the same at both bases, so the two collide only where the root is `$HOME`
itself: from `$HOME`, or under a home that holds `.git`. The paths then name one
set of files, which serve every repository, so they count as global. `--check`
judges each file once, and `--local` is refused. A host that installs only
locally is exempt, since it has no global copy to overwrite.

A file's mode is the one its location implies. A stub rendered for local mode
and carried to the global path reads as drifted, because the commands inside
it are wrong where it sits. When both a global and a local copy of a skill
exist, Claude Code loads the global one; `install` and `--check` say so.
Codex loads both, so its local copy is reported as `stale` once a global
install resolves.

### A host that supports only one

A host whose skills only mean anything inside one repository — they read that
repo's files, or drive its history — has no use for global mode, and a stray
`yourtool install` would write stubs under `$HOME` that then shadow the
per-repo ones. Declare the modes it actually supports:

```python
HOST = Host(..., modes=("local",))
```

The first mode listed is what a flagless `install` uses and what printed
bodies render `{cli}` for before anything is installed, so the flag becomes
optional rather than mandatory. Asking for the other mode (`--global` here) is
an error naming the host's modes, not a silent redirect, and `pkgskills.install`
refuses it too. The checks that only make sense across two bases — a per-repo
copy gone stale under a global install, a global skill shadowing a local stub
— are skipped, since neither can happen.

This is a per-host constraint. Which mode a *particular repository* expects is
a separate question, and not one `pkgskills` answers yet.

## Documents

A skill body that says "read `references/fields.md` before rewriting anything"
works while the body is a file in a skill directory and stops working the
moment it is printed from a package: there is no directory next to the stub,
and the path resolves to nothing. A `Doc` is that sidecar, declared:

```python
HOST = Host(
    ...,
    docs=(Doc(name="add/fields", source="skills/add/references/fields.md"),),
)
```

The body then says `{cli} doc add/fields`, and the document is printed the same
way a skill body is — frontmatter stripped, `{cli}` resolved for the mode the
install actually resolves to. Because it loads only when a step asks for it,
the detail stays out of context until it is needed.

A doc is not an artifact. It is never written, stamped, checked, or removed;
`install`, `install --check`, and `pkgskills check` do not know it exists, and
the only place it has to ship is the wheel. Names may contain `/` so a host can
namespace its documents by the skill that owns them; that is a convention, not
something `pkgskills` interprets. A doc's name and its source are independent,
which is what lets the file live inside that skill's own directory —
`skills/add/references/fields.md` — so the shipped tree matches the spec's
skill layout while the body still writes `{cli} doc add/fields`. Since nothing else ever reads a doc's `source`, a
missing one is rejected when the `Host` is constructed rather than when a model
runs the command.

A host that would rather keep a print command of its own can: build the body
with `pkgskills.render_prompt(text, host.invocation(mode))` and get `mode` from
`pkgskills.printing_mode(host, root)`, which is what `skill` and `doc` use — the
installed mode when there is one, the host's default before the first install.
Resolving it any other way prints commands that do not run.

## The stamp and the check

Every generated file carries five keys under its frontmatter's `metadata`, and a
rule or agent whose source has no frontmatter gains a block to hold them:

```yaml
---
metadata:
  pkgskills-host: "yourtool"
  pkgskills-host-version: "1.4.0"
  pkgskills-version: "0.1.0"
  pkgskills-mode: "local"
  pkgskills-note: "generated; install overwrites edits"
---
```

Those keys are the only place the releases are recorded. They join whatever
`metadata` the source declares, after its own keys, so `metadata.version` stays
free to mean the skill's own version. The `pkgskills-` prefix is reserved: a source
declaring a key in it fails `assert_spec_conformant` and cannot be rendered.

`install --check` re-renders each artifact and compares, with the stamp's two
version values masked, so upgrading either package never flags a file whose
content did not change. What sits at the path decides the verdict:

| At the path | Status | Reason reported |
|---|---|---|
| nothing | `missing` | not installed |
| our render, any versions | `ok` | |
| our stamp, different content | `drifted` | content differs, or rendered for the other mode |
| the HTML comment an earlier `pkgskills` wrote | `drifted` | the stamp now lives in frontmatter, or rendered for the other mode |
| a stamp the host declared in `legacy_stamps` | `drifted` | stamped before the host adopted pkgskills, or rendered for the other mode |
| a local copy a global install superseded | `stale` | still loaded; remove it |
| a file with no stamp | `foreign` | hand-written or from an older release |
| another package's stamp | `foreign` | generated by that package |
| a symlink, dangling or live | `foreign` | a symlink, not a plain file |
| a directory | `foreign` | a directory, not a file |
| unreadable or not UTF-8 | `foreign` | unreadable |

`install` refuses to touch a foreign file without `--force`, and it checks
every target before writing the first one, so a refused rule never leaves a
half-installed skill behind. With `--force`, a symlink is replaced by a plain
file rather than written through. A fresh global install removes per-repo
copies the host generated earlier; a local install never deletes the global
copy that serves other repositories.

`install --check` ends with what to run. `repair:` is the plain `install`, which
touches only generated files and appends missing lines. `overwrite:` is the
`--force` form and names what it would replace; it appears for a foreign file or
a drifted line, either of which may be deliberate. A mode holding a foreign file
gets no `repair:`, since a plain install there refuses.

### Migrating from your own stamps

A host that stamped its generated files before adopting pkgskills leaves those
files on every machine that installed an earlier release, and they read
`foreign` until someone passes `--force`. Declare the old stamp and a bare
`install` upgrades them:

```python
Host(
    ...,
    legacy_stamps=(
        re.compile(r"<!-- yourtool \S+ \((?P<mode>global|local)\); do not edit -->"),
    ),
)
```

Each pattern has to match a whole line and capture the recorded mode as a named
group `mode`, so a stamp that recorded no mode cannot be declared. Construction
rejects a pattern with no such group, and one that matches a stamp `pkgskills`
wrote itself: any line of the current map, or the HTML comment of earlier releases,
which is recognized without a declaration.

A matching file counts as generated everywhere. It reads `drifted`, a bare
`install` overwrites it, edits included, and it is removed as a stale local copy
or at a previous name. That is how a current stamp is treated, so declare a
pattern only for a stamp that also told readers not to edit the file, and match
it exactly, with any version token as `\S+`. A loose pattern hands `install` a
hand-written file. Drop a pattern once no supported upgrade starts from a release
that wrote it; pkgskills cannot tell when that is.

### Renaming an artifact

A host that renames a rule, skill, or agent leaves the old file behind at
every consumer — still auto-loading, still stamped as the host's. Declare the
old name and `install` cleans it up:

```python
Rule(name="yourtool", source="rules/yourtool.md", previous_names=("plan-files",))
```

At each previous name, the file that would sit there in the install mode is
removed **only if it carries this host's stamp** — the same test a stale local
copy gets. `install --check` reports such a leftover as `stale` and says
`remove:`. A file at an old name without the stamp is somebody else's: it is
left alone, mentioned in a note, and never gates. A single-source skill's stub
lifts the source's `name`, so renaming one means renaming the source's
frontmatter as well; a dispatcher renames from the declaration alone.

`stale` is the asymmetric case. Claude Code auto-loads rules and agents from
the global *and* the local location at once, so a per-repo copy left behind
after a switch to global is an extra file live in context whatever its content
says. The verdict therefore outranks both `ok` and `drifted`: the check gates on
it and says `remove:`, not `repair:` — rewriting the file is not the fix, and a
reinstall would only recreate it. Claude Code skills are exempt, because a
global skill shadows the local stub rather than loading alongside it. Codex
skills load from both bases, so its leftover local stubs are `stale` too;
which kinds load from both bases is declared on each `Harness`. A *global*
copy during a local install is never flagged: it is shared infrastructure
serving every other repository.

## One line in a file the host does not own

Some generated files need one line in a file that belongs to the repository:
the motivating case is a `merge=union` attribute for a generated index, so it
resolves on merge instead of conflicting. Declare the line and `install`
keeps it there:

```python
HOST = Host(
    ...,
    lines=(Line(path=".gitattributes", key="docs/README.md", value="merge=union"),),
)
```

`key` is the line's first field, the token that identifies it; `value` is the
rest. `install` appends the line when no line names the key, and rewrites a
line that names the key with a different value only under `--force` — the
file is repo content a user may have set deliberately, so a plain install
reports the difference and leaves it. Other lines are never touched. The line
goes in the repository whatever the install mode, since it has no meaning
under `$HOME`.

`install --check` shows each line under its path with a blank mode column:

| Found | Status |
|---|---|
| a line naming the key with the value | `ok` |
| a line naming the key with another value | `drifted` (the check says what the file gives) |
| no line naming the key, or no file | `missing` |
| a file that cannot be read | `unreadable` |

All four but `ok` gate. `unreadable` is its own status rather than folding into
`foreign` because `--force` cannot fix it: an installer that cannot read the
file refuses to rewrite it, since every edit here is an append or a one-line
amendment of text it has read, and a status must not imply a remedy the tool
will not perform. A line is not stamped and not an artifact; it renders from
nothing, and a stamp has no place inside another tool's file.

## Wiring pre-commit hooks

A host that ships a `validate` or `index` command usually wants it to run as
a git hook. `pkgskills.precommit` does the wiring; the host declares only the
hooks and calls it from the two host hooks:

```python
from pkgskills import Hook, InstallReport
from pkgskills import precommit

HOOKS = (
    Hook("yourtool-validate", args=("validate",), files=r"^docs/plans/.*\.md$"),
    Hook(
        "yourtool-index",
        stage="post-merge",
        args=("index", "."),
        always_run=True,
        pass_filenames=False,
    ),
)
```

`pass_filenames` follows pre-commit's default (true), so a file-scoped hook
like `validate` gets the matched paths on its command line; a whole-repo hook
that takes none, like the `post-merge` index above, turns it off explicitly.

```python
def after_install(report: InstallReport) -> None:
    precommit.wire(report, HOOKS)


HOST = Host(
    ...,
    after_install=after_install,
    extra_checks=lambda host, root, mode: precommit.checks(host, root, mode, HOOKS),
)
```

`wire` adds a `repo: local` block naming the hooks that are missing from
`.pre-commit-config.yaml` (creating the file when absent, and seeding `repos:`
in one that is empty or comments only), and runs
`pre-commit install --hook-type <stage>` for each distinct stage. Each hook's
`entry` is the host's invocation for the install mode plus the hook's `args`,
so it can only be mode-correct, and its `stages` is always written, so it runs
only at the stage it declares; `name` and `files` are single-quoted, so a
regex or a display name may carry `: ` or ` #`. Four rules keep it from
clobbering repo content:

- **The block is a text edit, checked before it is written.** Comments,
  formatting, and line endings survive, and any block layout of the `repos` list works, as does
  `repos: []`, with other keys before or after it. The result must parse to the
  original document plus the new repo. A config where that cannot hold (invalid
  YAML, no `repos` key, a non-empty flow list) is left alone, and
  `HookReport.unsupported` says so; the hooks then need adding by hand.
- **A hook already present is keyed on its id**, read from the parsed config, so
  a customized entry counts as present and is not duplicated however it is
  written, and an id that is a prefix of another (`x-index` next to
  `x-index-all`) is not mistaken for it.
- **An entry naming the other mode is resynced only on a genuine switch** —
  when `InstallReport.previous`, the mode resolved before the write, differs
  from the mode being installed. A same-mode refresh leaves it: a host's own
  dev repo keeps `uv run yourtool` while using a global stub, since the host is
  a local dependency there. An entry naming neither invocation is always left.
- **An unreadable config is never rewritten**; `HookReport.unreadable` says so.

`wire` returns a `HookReport`: whether the config was created, which hook ids
were added or resynced, and per stage what registration did — `activated`,
`already_active`, `skipped` (with `activate=False`), `no_git_repo`,
`hookspath_blocked` (git's `core.hooksPath` makes `pre-commit install` refuse),
or `unavailable`. `pre-commit` itself is invoked bare in global mode and
through the host's `local_prefix` in local mode; pass `precommit=` to
override. `precommit.add_dependency(root)` runs `uv add --dev pre-commit`, and
is a separate call because it edits `pyproject.toml`, which a default install
should not do unasked.

`checks` returns one row per hook — `active`, `unregistered`, `missing` (no
entry in the config), `no_git_repo`, or `blocked` — none of which gate, since
registration is per-clone state a fresh clone legitimately lacks. Each row's
note names the command that fixes it. Registration is judged by the hook
script pre-commit writes into the effective hooks directory, which honors
`core.hooksPath` and linked worktrees, so a hook live at a non-default path is
still found.

## Hooks for host-specific work

`Host.after_install` receives an `InstallReport` once every artifact is on
disk: the mode, the root, the paths written, removed, and shadowed, the mode
that was installed before (`previous`), the user's `force` consent, what
happened to each declared line, and the files removed or left at previous
names. Use it for follow-up the library cannot know about; shell out through
`pkgskills.run(root, argv)`, which pins the call to `root` and strips `GIT_DIR`
and its siblings, so an inherited location variable cannot aim a commit at
another repository.

`Host.extra_checks` is the read side of the same idea. Called with `(host,
root, mode)` during `install --check`, it returns `ExtraCheck(label, status,
gates, note)` rows for per-clone state `pkgskills` cannot see. They print in the
same table, and only the rows that say they gate fold into the exit code — a
hook that is not registered in this clone deserves a line without calling a
correct install broken.

Hosts that need extra flags keep their own `install` command and call
`pkgskills.install(host, root, mode, force=...)` and
`pkgskills.check(host, root, mode)` directly.

## Automation levels

A host whose skills tell the model to run commands can declare, per level, the
Bash allow-rules that level adds:

```python
HOST = Host(
    ...,
    permissions={
        Level.assist: ("Bash(git add:*)", "Bash(git commit:*)", "Bash(uv run:*)"),
        Level.confirm: ("Bash(git push:*)",),
        Level.full: ("Bash(gh pr merge:*)",),
    },
)
```

The levels are an escalating, superset ladder named by supervision posture —
`none` (grant nothing), `assist` (local and reversible), `confirm` (adds the
publishing step), `full` (adds the irreversible one) — with `0`–`3` as
aliases. `yourtool permissions` prints the cumulative union for a level; the
grant for calling the CLI itself is derived from the host's invocation, so it
appears for a global profile and collapses into `Bash(uv run:*)` for a local
one.

`--apply` merges into `.claude/settings.local.json` (or `~/.claude/settings.json`
with `--global`). The merge is additive and never downgrades: a rule already on
`deny` or `ask` stays there and is reported as skipped, a rule already allowed
is a no-op, and an apply with nothing to add leaves the file untouched. So the
command is safe to run blind.

### Codex sandbox

In Codex's `workspace-write` sandbox, the workspace and `/tmp` are writable,
with protected directories inside those roots:

- Every command that writes git state (`git commit`, `git branch`,
  `git worktree add`, `git merge`, `git tag`, and similar operations) fails
  because `.git` is read-only, whether it is a directory or a pointer file.
  In a linked worktree, the resolved git directory is protected too.
- `install --local` fails when the `.agents/` directory already exists:
  Codex keeps it read-only recursively even inside the workspace. `.codex/`
  and `.aws/` directories are protected the same way.
- A global `install` writes under `~/.claude/` and `~/.agents/`, outside the
  workspace, so it also needs permission to run outside the sandbox.
- `install --check` and `skill`/`doc` printing are read-only and run sandboxed.

Approve the escalation when prompted with the `on-request` approval policy,
grant the needed command prefixes with `yourtool permissions --codex`, or run
the command outside Codex. `--codex` prints a paste-ready execution-policy
fragment for the requested level. Its target is
`.codex/rules/yourtool.rules` locally (loaded only for a trusted project), or
`~/.codex/rules/default.rules` with `--global`.

An `allow` rule runs matching commands **outside the sandbox without
prompting**. Codex also accepts `prompt` and `forbidden`; the most restrictive
matching decision wins. Translation covers `Bash(<words>:*)` and
`Bash(<words> *)` with a trailing ` *`, splitting the words the way a shell
would into a command prefix. An exact `Bash(<words>)` is not translated:
Codex matches by prefix only, so the rule would grant every extension of the
command as well. A rule with shell syntax (`&&`, `|`, `;`, a redirection, a
`$` expansion, a leading `~`) is not translated either: Codex matches the
parsed argv, which never contains those tokens. Exact rules, shell syntax,
other wildcards, and non-Bash tools appear as `# not translated:` comments. Review the fragment and paste it into the target file;
`--codex --apply` is refused, and pkgskills never writes Codex `.rules` files.

## The `pkgskills` script

```bash
pkgskills hosts    # every host registered in this environment
pkgskills check    # run each host's drift check from the current repository
```

## Testing a host

`pkgskills.testing.sandbox(tmp_path, monkeypatch)` pins `$HOME` and the working
directory to fresh directories, so a suite never touches the developer's real
`~/.claude` or `~/.agents`.
`pkgskills.testing.wheel_files(project_root, out_dir)` builds a wheel
in-process and lists its contents, which is the only way to prove the prompts
ship: an editable install resolves package data straight to the checkout.

`pkgskills.testing.assert_spec_conformant(host)` checks every skill the host
ships against the
[Agent Skills specification](https://agentskills.io/specification):
that each source is stored as `<name>/SKILL.md`, that its frontmatter carries a
`name` matching the directory and satisfying the spec's grammar, that a
`description` is present, that `metadata` is the map of strings the spec calls
for (an unquoted `version: 1.0` is a float, not a string), and that no field
runs past its limit. Every
violation is reported at once, each naming the rule it breaks and the fix — see
[docs/source-layout.md](docs/source-layout.md#checking-a-host-against-the-spec).

That includes the layout, so a host that adopts the helper as written uses
`<name>/SKILL.md` for every source, a dispatcher's included. A host that keeps
flat sources on purpose filters the report instead
([why](docs/source-layout.md#flat-sources-still-work)):

```python
violations = [v for v in HOST.check_spec() if v.rule != "entry-file"]
assert not violations, "\n".join(map(str, violations))
```

`pkgskills.testing.assert_prompt_commands(host, app)` closes the loop the whole
pattern exists for. `pkgskills` renders `{cli}`, but nothing otherwise checks
that what follows it is a command the host actually has, and prose about a CLI
goes stale. The helper scans every skill body, doc, rule, and agent for `{cli}
...` mentions, resolves each command path against the typer app, and resolves
the argument to `skill`, `doc`, `rule`, and `agent` against the host's own
declarations — so a renamed doc or a dropped subcommand fails the suite with the
source and line of every mention that no longer reaches anything:

```python
def test_prompts_are_well_formed() -> None:
    assert_spec_conformant(HOST)
    assert_prompt_commands(HOST, app)
```

A mention counts when it is written as code — inside backticks or a fenced
block. Prose that names the bare placeholder ("`{cli}` is substituted per
mode") is talking *about* the token, so the words after it are not read as a
command path. `pkgskills.testing.prompt_commands(host)` returns the same
mentions as `PromptCommand` records (source, line, command path, declared
argument) for a suite that wants to assert something else about them.

## Development

```bash
uv sync --all-groups
uv run pytest
uv run ruff check . && uv run ruff format --check .
uv run pyrefly check
```

`tests/fixtures/` holds three throwaway hosts that the suite drives end to
end: one with every artifact kind, one with a single skill body, and one with
several single-source skills that is also local-only, ships reference
documents inside their own skill directories, and declares `render_cli` once on
the host. All three store their skills the way the spec does, so the tree a
host author copies is conformant as it stands; `brokenhost` is the fourth, and
holds the counterexamples the negative tests need.
