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.
- pkgskills-0.2.0/.gitignore +26 -0
- pkgskills-0.2.0/CHANGELOG.md +160 -0
- pkgskills-0.2.0/LICENSE +22 -0
- pkgskills-0.2.0/PKG-INFO +370 -0
- pkgskills-0.2.0/README.md +358 -0
- pkgskills-0.2.0/pkgskills/__init__.py +68 -0
- pkgskills-0.2.0/pkgskills/artifacts.py +420 -0
- pkgskills-0.2.0/pkgskills/cli.py +495 -0
- pkgskills-0.2.0/pkgskills/frontmatter.py +199 -0
- pkgskills-0.2.0/pkgskills/harness.py +87 -0
- pkgskills-0.2.0/pkgskills/host.py +481 -0
- pkgskills-0.2.0/pkgskills/permissions.py +223 -0
- pkgskills-0.2.0/pkgskills/proc.py +78 -0
- pkgskills-0.2.0/pkgskills/py.typed +0 -0
- pkgskills-0.2.0/pkgskills/rendering.py +209 -0
- pkgskills-0.2.0/pkgskills/spec.py +499 -0
- pkgskills-0.2.0/pkgskills/stamp.py +129 -0
- pkgskills-0.2.0/pkgskills/testing.py +301 -0
- pkgskills-0.2.0/pyproject.toml +106 -0
|
@@ -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
|
pkgskills-0.2.0/LICENSE
ADDED
|
@@ -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
|
+
|
pkgskills-0.2.0/PKG-INFO
ADDED
|
@@ -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.
|