pkgskills 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,26 @@
1
+ .archive/
2
+ .claude/
3
+ .hypothesis/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .venv/
7
+ .vscode/
8
+ .worktrees/
9
+
10
+ __pycache__/
11
+ build/
12
+ dist/
13
+ *.egg-info/
14
+
15
+ .DS_Store
16
+
17
+ .coverage
18
+ .coverage.*
19
+
20
+ .env
21
+ .env.*
22
+ !.env.example
23
+
24
+ # local working notes, not part of the package
25
+ /notes.md
26
+ /plan.md
@@ -0,0 +1,160 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-09-11
11
+
12
+ ### Changed
13
+
14
+ - Renamed the package from `mli` to `pkgskills`, since PyPI refuses `mli` as
15
+ too similar to an existing project. The distribution, the import package, the
16
+ console script, the `pkgskills.hosts` entry-point group, and the
17
+ `pkgskills_sandbox` fixture all carry the new name.
18
+ - The stamp now reads `via pkgskills X` and skill stubs carry
19
+ `metadata.pkgskills-version`, so every stub installed under the old name
20
+ reports drift once; reinstall it with `--force`.
21
+
22
+ ### Fixed
23
+
24
+ - `Host.permissions` defaults through a factory, so importing the package no
25
+ longer fails on Python 3.11-3.13 with a mutable-default dataclass error.
26
+
27
+ ## [0.1.0] - 2026-09-09
28
+
29
+ ### Added
30
+
31
+ - `Host` declaration: distribution name, CLI name, prompt package, and the
32
+ skills, rules, agents, and reference documents a package ships, with an
33
+ `after_install` hook.
34
+ - `register(app, host)` mounts the shared grammar on a host's typer app:
35
+ `skill`, `install`, and, when declared, `doc`, `rule` and `agent`.
36
+ - `Doc` declares a reference document a skill body loads mid-step. It is
37
+ printed by `<cli> doc <name>` — the same render a skill body gets — and is
38
+ never installed, stamped, or checked, so it sits on `Host.docs` rather than
39
+ in `Host.artifacts`. Names may contain `/` for namespacing, and a source
40
+ that does not exist is rejected at construction.
41
+ - `printing_mode(host, root)` and `installed_mode(host, root)` are exported,
42
+ so a host with a print command of its own resolves `{cli}` the way `skill`
43
+ and `doc` do.
44
+ - `Host.render_cli` is the default for every artifact and doc that leaves its
45
+ own `render_cli` unset, for a host whose every body uses the placeholder. An
46
+ explicit flag on a declaration still wins.
47
+ - Skills install as print-on-demand stubs, each naming the body it prints
48
+ (`<cli> skill <name>`); a multi-source skill becomes a dispatcher whose
49
+ subcommands are the source names, while a single-source skill is addressed
50
+ by the skill's own name, whatever its source file is called. Rules and
51
+ agents install as stamped copies.
52
+ - Global (`~/.claude/`) and local (repository) install modes, with the
53
+ repository root found by walking up to `.git` or `.claude/`.
54
+ - `Host.modes` declares which install modes a host supports, in preference
55
+ order; the first is used by a flagless `install` and by `{cli}` rendering
56
+ before anything is installed. A host bound to one repository declares
57
+ `modes=("local",)`: `--global` is then refused by name, `mli.install` refuses
58
+ it too, and the stale-local and shadowed-stub checks are skipped. `install`
59
+ takes `--local/--global` rather than only `--local`.
60
+ - Skill stubs declare the host and `mli` versions as frontmatter `metadata`
61
+ (`version`, `mli-version`), the field the [Agent Skills
62
+ specification](https://agentskills.io/specification#frontmatter-required)
63
+ reserves for client properties; `docs/frontmatter.md` covers the shape.
64
+ - One stamp format naming the host version, the `mli` version, the mode, and
65
+ the regenerate command; `install --check` masks every version and reports
66
+ `ok`, `drifted`, `missing`, or `foreign` with a reason per file.
67
+ - Foreign files (unstamped, another package's stamp, symlinks, directories,
68
+ undecodable bytes) are never replaced without `--force`, and every target
69
+ is guarded before the first write.
70
+ - `{cli}` placeholder rendering per mode for artifacts and docs that opt in.
71
+ - `mli hosts` and `mli check` over the `mli.hosts` entry-point group.
72
+ - `mli.testing.sandbox` and `mli.testing.wheel_files` for host test suites.
73
+ - `mli.testing.prompt_commands(host)` and
74
+ `mli.testing.assert_prompt_commands(host, app)` check that every `{cli} ...`
75
+ mention in a host's skills, docs, rules, and agents names a command the typer
76
+ app really has, and that the argument to `skill`, `doc`, `rule`, or `agent`
77
+ names something the host declares. Mentions count only where they are written
78
+ as code, so prose about the placeholder is not read as a command.
79
+ - Claude Code as the first harness adapter, with the layout kept in one
80
+ `Harness` value.
81
+ - A shared `permissions` command, mounted when a host declares
82
+ `Host.permissions`: an escalating, superset ladder of automation levels
83
+ (`none`/`assist`/`confirm`/`full`, or `0`-`3`) where the host supplies only
84
+ its own per-level increments and `mli` derives the grant for calling the CLI
85
+ from the host's invocation. Prints a paste-ready block by default; `--apply`
86
+ merges additively into `.claude/settings.local.json` (or
87
+ `~/.claude/settings.json` with `--global`), never downgrading a rule already
88
+ on `deny` or `ask`.
89
+ - `Host.extra_checks`, the read-side counterpart to `after_install`: a host
90
+ returns `ExtraCheck(label, status, gates, note)` rows for per-clone state
91
+ `mli` cannot derive (a registered pre-commit hook, say), they print in the
92
+ shared `install --check` table, and only the rows that say they gate fold
93
+ into the exit code — so a host with extra state keeps the shared table
94
+ instead of writing its own `install` command.
95
+ - `mli.run` (and `mli.LOCATION_ENV`): a subprocess helper pinned to an explicit
96
+ repo root with git's location variables (`GIT_DIR` and friends) stripped, so
97
+ a host's `after_install` hook cannot have its shell-outs redirected at another
98
+ repository by an inherited environment variable.
99
+ - Skill sources may be stored in the Agent Skills spec's own layout — a
100
+ directory named for the skill, holding its `SKILL.md` and its `references/` —
101
+ so a host's `prompts/` tree passes a skills linter as it stands. A source
102
+ named exactly `SKILL.md` takes its name from its parent directory; any other
103
+ file keeps naming itself by its stem, so flat sources are unchanged and the
104
+ two layouts may be mixed. A `Doc`'s name and source stay independent, which
105
+ is what lets a namespaced doc (`add/fields`) be sourced from inside the
106
+ skill that owns it (`skills/add/references/fields.md`). See
107
+ [docs/source-layout.md](docs/source-layout.md).
108
+ - `Skill.name` is validated against the spec's `name` grammar at construction:
109
+ 1-64 characters of `a-z`, `0-9`, and hyphens, with no leading, trailing, or
110
+ consecutive hyphen. `Doc.name` is deliberately exempt, since a doc is never
111
+ installed as a skill and may namespace itself with `/`.
112
+ - `mli.spec` holds the Agent Skills specification as data — `SkillSpec` carries
113
+ the entry filename, the optional directories, every frontmatter field with
114
+ its length limit, and the `name` grammar; `SPEC` is the shipped instance.
115
+ A departure is a `Violation` naming the rule it breaks and the fix for it,
116
+ and failures raise `SpecError` (a `ValueError`) carrying them as data rather
117
+ than only as a message.
118
+ - `Host.check_spec()` returns every violation across a host's skills, and
119
+ `mli.testing.assert_spec_conformant(host)` is the one-line form for a host's
120
+ own test suite — the counterpart to `assert_prompt_commands`. All violations
121
+ are reported at once, grouped by file. Construction still checks only the
122
+ declared names, so declaring a host does not walk its prompt package.
123
+ - The spec check covers `metadata`, which the spec defines as a map from string
124
+ keys to string values: a scalar or sequence where a mapping belongs, a nested
125
+ mapping under a key, and — the one that bites — a value YAML resolves to
126
+ something other than a string (`version: 1.0` is a float, `retries: 3` an
127
+ integer, `enabled: true` a boolean). The fix names the quoted form.
128
+ - `mli.frontmatter.find_block(raw, key)` returns the raw indented block a
129
+ frontmatter key opens, as a `Block` with the inline value and per-line
130
+ `entries()`. It backs both the `metadata` spec check and the version-key
131
+ splice in `with_metadata`, which previously walked the raw lines itself and
132
+ stopped at the first blank line inside the mapping. It reads a key declared
133
+ twice as YAML does — last one wins, matching `parse_fields` — and treats a
134
+ trailing `# comment` as a comment rather than as part of the value, so a
135
+ commented scalar is judged by what YAML would resolve it to.
136
+
137
+ ### Changed
138
+
139
+ - `install --check` reports a new `stale` status and gates on it: a per-repo
140
+ copy of a kind the harness loads from *both* bases (rules and agents under
141
+ Claude Code) is drift once a resolved global install serves the repo,
142
+ whatever its content says — it is an extra file live in context, so the
143
+ verdict outranks both `ok` and `drifted` and the remedy printed is `remove:`,
144
+ never a reinstall that would recreate the file. Skills
145
+ are unaffected, since a global skill shadows the local stub rather than
146
+ loading alongside it, and a global copy is never flagged during a local
147
+ install.
148
+ - `Harness` declares that discipline per kind (`shadowed_kinds`, with
149
+ `shadows()` / `both_load()`), so it is a property of the harness rather than
150
+ something hardcoded at the call site.
151
+ - `installed_mode` falls back to a host's other artifacts when it ships no
152
+ skills, instead of always answering `None`.
153
+
154
+ ### Deprecated
155
+
156
+ ### Removed
157
+
158
+ ### Fixed
159
+
160
+ ### Security
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ronald E. Robertson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
@@ -0,0 +1,370 @@
1
+ Metadata-Version: 2.5
2
+ Name: pkgskills
3
+ Version: 0.2.0
4
+ Summary: Ship prompts inside a CLI package and install thin, version-stamped stubs where the harness reads them.
5
+ Project-URL: repository, https://github.com/gitronald/pkgskills
6
+ Author-email: gitronald <gitronald@users.noreply.github.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: typer
11
+ Description-Content-Type: text/markdown
12
+
13
+ # pkgskills
14
+
15
+ Ship prompts inside a CLI package and install thin, version-stamped stubs
16
+ where the harness reads them.
17
+
18
+ A *host* is a Python package that bundles its Claude Code skills, rules, and
19
+ subagent definitions as package data. `pkgskills` gives that package three
20
+ things:
21
+
22
+ - a `skill` command that prints a bundled prompt on demand, so the text an
23
+ agent reads always comes from the installed version and there is no copy to
24
+ go stale;
25
+ - an `install` command that materializes the files the harness must read off
26
+ disk, each stamped with the host and `pkgskills` versions, the install mode,
27
+ and the command that regenerates it;
28
+ - an `install --check` that tells a current file from a drifted, missing, or
29
+ foreign one and exits non-zero unless everything is `ok`.
30
+
31
+ Skills are installed as *stubs*: the frontmatter the harness needs to know
32
+ when to fire, plus an instruction to run `<cli> skill <name>` and follow the
33
+ output.
34
+ Rules and agents are installed as *copies*, because the harness reads their
35
+ full text with no model in the loop. Both carry the same stamp and the same
36
+ drift check. Reference *documents* are the fourth thing a host declares and
37
+ the one that is never installed at all: they are printed by `<cli> doc <name>`,
38
+ which is how a body defers detail to a sidecar it can no longer reach by path.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ uv add pkgskills
44
+ ```
45
+
46
+ Python 3.11 or later. The only runtime dependency is typer.
47
+
48
+ ## Declare a host
49
+
50
+ Put the prompts inside the package (hatchling ships `.md` files under a
51
+ package directory by default) and declare them once:
52
+
53
+ ```python
54
+ # yourtool/cli.py
55
+ import typer
56
+
57
+ from pkgskills import Agent, Doc, Host, Rule, Skill, register
58
+
59
+ HOST = Host(
60
+ dist="yourtool", # distribution name, for the version lookup
61
+ cli="yourtool", # bare command; local mode prefixes `uv run`
62
+ prompts="yourtool.prompts", # package holding the prompt files
63
+ artifacts=(
64
+ Skill(
65
+ name="yourtool",
66
+ sources=("skills/add/SKILL.md", "skills/close/SKILL.md"),
67
+ ),
68
+ Rule(name="yourtool", source="rules/yourtool.md", render_cli=True),
69
+ Agent(name="yourtool-reviewer", source="agents/reviewer.md"),
70
+ ),
71
+ docs=(Doc(name="add/fields", source="skills/add/references/fields.md"),),
72
+ )
73
+
74
+ app = typer.Typer()
75
+ register(app, HOST) # adds skill, install, doc, rule, agent
76
+ ```
77
+
78
+ Each skill source is a spec-conformant skill directory — `<name>/SKILL.md`,
79
+ holding the skill's `references/` and `scripts/` too — so the `prompts/` tree
80
+ passes a skills linter as it stands. The `skills/` group above is a
81
+ convenience, not a requirement: `pkgskills` reads only the last two components
82
+ of the path, so a host that ships nothing but skills can drop it and write
83
+ `sources=("add/SKILL.md",)`. A flat `skills/add.md` still works and may be
84
+ mixed in. `assert_spec_conformant(HOST)` reports anything misfiled, naming the
85
+ rule and the fix — see
86
+ [docs/source-layout.md](docs/source-layout.md).
87
+
88
+ A skill with one source lifts that file's frontmatter into the stub, adding
89
+ the host and `pkgskills` versions under `metadata` (see
90
+ [docs/frontmatter.md](docs/frontmatter.md)). Its body is addressed by the
91
+ *skill's* name — `<cli> skill use-solo` — so the source need not be named
92
+ after it. A skill with several sources becomes a dispatcher: each source's
93
+ name — its directory, or its stem for a flat file — is a subcommand, and the
94
+ stub tells the agent to run `<cli> skill <subcommand>`. The two namespaces
95
+ share one argument, so a dispatcher subcommand may not collide with another
96
+ skill's name; `Host` rejects that at construction, as it rejects a
97
+ `Skill.name` outside the spec's grammar.
98
+
99
+ A body must not point at a file by a path relative to the stub: after
100
+ `install` the stub is alone in its directory and there is nothing there to
101
+ read. Ship the file as a `Doc` and refer to it as `{cli} doc <name>` — see
102
+ [Documents](#documents).
103
+
104
+ Set `render_cli=True` on an artifact or doc whose body uses the `{cli}`
105
+ placeholder; it is rendered as `yourtool` for a global install and
106
+ `uv run yourtool` for a local one, so printed commands run as written. When
107
+ every body a host ships uses the token, set `render_cli=True` on the `Host`
108
+ instead and leave the declarations alone; an explicit flag on a declaration
109
+ still wins, so one body can opt back out.
110
+
111
+ Register the host under the `pkgskills.hosts` entry-point group and the
112
+ `pkgskills` script can find it:
113
+
114
+ ```toml
115
+ [project.entry-points."pkgskills.hosts"]
116
+ yourtool = "yourtool.cli:HOST"
117
+ ```
118
+
119
+ ## The commands a host gains
120
+
121
+ | Command | Does |
122
+ |---|---|
123
+ | `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. |
124
+ | `yourtool doc [NAME] [--list]` | Print a reference document (only when the host ships docs). |
125
+ | `yourtool rule [NAME] [--list]` | Print a rule (only when the host ships rules). |
126
+ | `yourtool agent [NAME] [--list]` | Print an agent definition (only when the host ships agents). |
127
+ | `yourtool install` | Write every artifact for the host's default mode — under `~/.claude/` unless the host restricts its `modes`. |
128
+ | `yourtool install --local` / `--global` | Write them under the enclosing repository, or under `~/.claude/`. Naming a mode the host does not declare is an error. |
129
+ | `yourtool install --check` | Report `ok`, `drifted`, `stale`, `missing`, or `foreign` per file; exit 1 unless all ok. |
130
+ | `yourtool install --force` | Replace files the host did not generate. |
131
+ | `yourtool permissions [--level L] [--global] [--apply]` | Print or apply an automation-level allow-rule profile (only when the host declares one). |
132
+
133
+ The enclosing repository is the nearest ancestor of the working directory
134
+ holding `.git` or `.claude/`, so a local install from a subdirectory still
135
+ lands where the harness loads from.
136
+
137
+ ## Modes
138
+
139
+ **Global** (default) puts one copy under `$HOME` that serves every repository;
140
+ the CLI is on `PATH` and invoked bare. **Local** puts the copy under the
141
+ repository root and invokes the CLI through `uv run`. The relative layout is
142
+ the same at both bases, so the two never collide.
143
+
144
+ A file's mode is the one its location implies. A stub rendered for local mode
145
+ and carried to the global path reads as drifted, because the commands inside
146
+ it are wrong where it sits. When both a global and a local copy of a skill
147
+ exist, the global one is what the harness loads; `install` and `--check` say
148
+ so.
149
+
150
+ ### A host that supports only one
151
+
152
+ A host whose skills only mean anything inside one repository — they read that
153
+ repo's files, or drive its history — has no use for global mode, and a stray
154
+ `yourtool install` would write stubs under `$HOME` that then shadow the
155
+ per-repo ones. Declare the modes it actually supports:
156
+
157
+ ```python
158
+ HOST = Host(..., modes=("local",))
159
+ ```
160
+
161
+ The first mode listed is what a flagless `install` uses and what printed
162
+ bodies render `{cli}` for before anything is installed, so the flag becomes
163
+ optional rather than mandatory. Asking for the other mode (`--global` here) is
164
+ an error naming the host's modes, not a silent redirect, and `pkgskills.install`
165
+ refuses it too. The checks that only make sense across two bases — a per-repo
166
+ copy gone stale under a global install, a global skill shadowing a local stub
167
+ — are skipped, since neither can happen.
168
+
169
+ This is a per-host constraint. Which mode a *particular repository* expects is
170
+ a separate question, and not one `pkgskills` answers yet.
171
+
172
+ ## Documents
173
+
174
+ A skill body that says "read `references/fields.md` before rewriting anything"
175
+ works while the body is a file in a skill directory and stops working the
176
+ moment it is printed from a package: there is no directory next to the stub,
177
+ and the path resolves to nothing. A `Doc` is that sidecar, declared:
178
+
179
+ ```python
180
+ HOST = Host(
181
+ ...,
182
+ docs=(Doc(name="add/fields", source="skills/add/references/fields.md"),),
183
+ )
184
+ ```
185
+
186
+ The body then says `{cli} doc add/fields`, and the document is printed the same
187
+ way a skill body is — frontmatter stripped, `{cli}` resolved for the mode the
188
+ install actually resolves to. Because it loads only when a step asks for it,
189
+ the detail stays out of context until it is needed.
190
+
191
+ A doc is not an artifact. It is never written, stamped, checked, or removed;
192
+ `install`, `install --check`, and `pkgskills check` do not know it exists, and
193
+ the only place it has to ship is the wheel. Names may contain `/` so a host can
194
+ namespace its documents by the skill that owns them; that is a convention, not
195
+ something `pkgskills` interprets. A doc's name and its source are independent,
196
+ which is what lets the file live inside that skill's own directory —
197
+ `skills/add/references/fields.md` — so the shipped tree matches the spec's
198
+ skill layout while the body still writes `{cli} doc add/fields`. Since nothing else ever reads a doc's `source`, a
199
+ missing one is rejected when the `Host` is constructed rather than when a model
200
+ runs the command.
201
+
202
+ A host that would rather keep a print command of its own can: build the body
203
+ with `pkgskills.render_prompt(text, host.invocation(mode))` and get `mode` from
204
+ `pkgskills.printing_mode(host, root)`, which is what `skill` and `doc` use — the
205
+ installed mode when there is one, the host's default before the first install.
206
+ Resolving it any other way prints commands that do not run.
207
+
208
+ ## The stamp and the check
209
+
210
+ Every generated file carries one HTML comment after its frontmatter (or on
211
+ the first line when there is none):
212
+
213
+ ```
214
+ <!-- generated by yourtool 1.4.0 via pkgskills 0.1.0 (mode=local); do not edit. Regenerate with: uv run yourtool install --local --force -->
215
+ ```
216
+
217
+ A skill stub also declares both versions as frontmatter `metadata`, the field
218
+ the [Agent Skills specification](https://agentskills.io/specification#frontmatter-required)
219
+ reserves for it, so a tool that reads only the frontmatter can tell which
220
+ release it has.
221
+
222
+ `install --check` re-renders each artifact and compares, with every version
223
+ token masked, so upgrading either package never flags a file whose content
224
+ did not change. What sits at the path decides the verdict:
225
+
226
+ | At the path | Status | Reason reported |
227
+ |---|---|---|
228
+ | nothing | `missing` | not installed |
229
+ | our render, any versions | `ok` | |
230
+ | our stamp, different content | `drifted` | content differs, or rendered for the other mode |
231
+ | a local copy a global install superseded | `stale` | still loaded; remove it |
232
+ | a file with no stamp | `foreign` | hand-written or from an older release |
233
+ | another package's stamp | `foreign` | generated by that package |
234
+ | a symlink, dangling or live | `foreign` | a symlink, not a plain file |
235
+ | a directory | `foreign` | a directory, not a file |
236
+ | unreadable or not UTF-8 | `foreign` | unreadable |
237
+
238
+ `install` refuses to touch a foreign file without `--force`, and it checks
239
+ every target before writing the first one, so a refused rule never leaves a
240
+ half-installed skill behind. With `--force`, a symlink is replaced by a plain
241
+ file rather than written through. A fresh global install removes per-repo
242
+ copies the host generated earlier; a local install never deletes the global
243
+ copy that serves other repositories.
244
+
245
+ `stale` is the asymmetric case. The harness auto-loads rules and agents from
246
+ the global *and* the local location at once, so a per-repo copy left behind
247
+ after a switch to global is an extra file live in context whatever its content
248
+ says. The verdict therefore outranks both `ok` and `drifted`: the check gates on
249
+ it and says `remove:`, not `repair:` — rewriting the file is not the fix, and a
250
+ reinstall would only recreate it. Skills are exempt, because a
251
+ global skill shadows the local stub rather than loading alongside it; which
252
+ kinds load from both bases is declared on the `Harness`. A *global* copy during
253
+ a local install is never flagged: it is shared infrastructure serving every
254
+ other repository.
255
+
256
+ ## Hooks for host-specific work
257
+
258
+ `Host.after_install` receives an `InstallReport` (mode, root, and the paths
259
+ written, removed, and shadowed) once every artifact is on disk. Use it for
260
+ follow-up such as wiring a pre-commit hook; shell out through
261
+ `pkgskills.run(root, argv)`, which pins the call to `root` and strips `GIT_DIR`
262
+ and its siblings, so an inherited location variable cannot aim a commit at
263
+ another repository.
264
+
265
+ `Host.extra_checks` is the read side of the same idea. Called with `(host,
266
+ root, mode)` during `install --check`, it returns `ExtraCheck(label, status,
267
+ gates, note)` rows for per-clone state `pkgskills` cannot see. They print in the
268
+ same table, and only the rows that say they gate fold into the exit code — a
269
+ hook that is not registered in this clone deserves a line without calling a
270
+ correct install broken.
271
+
272
+ Hosts that need extra flags keep their own `install` command and call
273
+ `pkgskills.install(host, root, mode, force=...)` and
274
+ `pkgskills.check(host, root, mode)` directly.
275
+
276
+ ## Automation levels
277
+
278
+ A host whose skills tell the model to run commands can declare, per level, the
279
+ Bash allow-rules that level adds:
280
+
281
+ ```python
282
+ HOST = Host(
283
+ ...,
284
+ permissions={
285
+ Level.assist: ("Bash(git add:*)", "Bash(git commit:*)", "Bash(uv run:*)"),
286
+ Level.confirm: ("Bash(git push:*)",),
287
+ Level.full: ("Bash(gh pr merge:*)",),
288
+ },
289
+ )
290
+ ```
291
+
292
+ The levels are an escalating, superset ladder named by supervision posture —
293
+ `none` (grant nothing), `assist` (local and reversible), `confirm` (adds the
294
+ publishing step), `full` (adds the irreversible one) — with `0`–`3` as
295
+ aliases. `yourtool permissions` prints the cumulative union for a level; the
296
+ grant for calling the CLI itself is derived from the host's invocation, so it
297
+ appears for a global profile and collapses into `Bash(uv run:*)` for a local
298
+ one.
299
+
300
+ `--apply` merges into `.claude/settings.local.json` (or `~/.claude/settings.json`
301
+ with `--global`). The merge is additive and never downgrades: a rule already on
302
+ `deny` or `ask` stays there and is reported as skipped, a rule already allowed
303
+ is a no-op, and an apply with nothing to add leaves the file untouched. So the
304
+ command is safe to run blind.
305
+
306
+ ## The `pkgskills` script
307
+
308
+ ```bash
309
+ pkgskills hosts # every host registered in this environment
310
+ pkgskills check # run each host's drift check from the current repository
311
+ ```
312
+
313
+ ## Testing a host
314
+
315
+ `pkgskills.testing.sandbox(tmp_path, monkeypatch)` pins `$HOME` and the working
316
+ directory to fresh directories, so a suite never touches the developer's real
317
+ `~/.claude`. `pkgskills.testing.wheel_files(project_root, out_dir)` builds a
318
+ wheel in-process and lists its contents, which is the only way to prove the
319
+ prompts ship: an editable install resolves package data straight to the
320
+ checkout.
321
+
322
+ `pkgskills.testing.assert_spec_conformant(host)` checks every skill the host
323
+ ships against the
324
+ [Agent Skills specification](https://agentskills.io/specification):
325
+ that each source is stored as `<name>/SKILL.md`, that its frontmatter carries a
326
+ `name` matching the directory and satisfying the spec's grammar, that a
327
+ `description` is present, that `metadata` is the map of strings the spec calls
328
+ for (an unquoted `version: 1.0` is a float, not a string), and that no field
329
+ runs past its limit. Every
330
+ violation is reported at once, each naming the rule it breaks and the fix — see
331
+ [docs/source-layout.md](docs/source-layout.md#checking-a-host-against-the-spec).
332
+
333
+ `pkgskills.testing.assert_prompt_commands(host, app)` closes the loop the whole
334
+ pattern exists for. `pkgskills` renders `{cli}`, but nothing otherwise checks
335
+ that what follows it is a command the host actually has, and prose about a CLI
336
+ goes stale. The helper scans every skill body, doc, rule, and agent for `{cli}
337
+ ...` mentions, resolves each command path against the typer app, and resolves
338
+ the argument to `skill`, `doc`, `rule`, and `agent` against the host's own
339
+ declarations — so a renamed doc or a dropped subcommand fails the suite with the
340
+ source and line of every mention that no longer reaches anything:
341
+
342
+ ```python
343
+ def test_prompts_are_well_formed() -> None:
344
+ assert_spec_conformant(HOST)
345
+ assert_prompt_commands(HOST, app)
346
+ ```
347
+
348
+ A mention counts when it is written as code — inside backticks or a fenced
349
+ block. Prose that names the bare placeholder ("`{cli}` is substituted per
350
+ mode") is talking *about* the token, so the words after it are not read as a
351
+ command path. `pkgskills.testing.prompt_commands(host)` returns the same
352
+ mentions as `PromptCommand` records (source, line, command path, declared
353
+ argument) for a suite that wants to assert something else about them.
354
+
355
+ ## Development
356
+
357
+ ```bash
358
+ uv sync --all-groups
359
+ uv run pytest
360
+ uv run ruff check . && uv run ruff format --check .
361
+ uv run pyrefly check
362
+ ```
363
+
364
+ `tests/fixtures/` holds three throwaway hosts that the suite drives end to
365
+ end: one with every artifact kind, one with a single skill body, and one with
366
+ several single-source skills that is also local-only, ships reference
367
+ documents inside their own skill directories, and declares `render_cli` once on
368
+ the host. All three store their skills the way the spec does, so the tree a
369
+ host author copies is conformant as it stands; `brokenhost` is the fourth, and
370
+ holds the counterexamples the negative tests need.