pkgskills 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """pkgskills: skills that ship inside a CLI's own package.
2
+
3
+ A package that ships prompts as package data declares itself as a
4
+ :class:`Host`, and ``pkgskills`` gives it a ``skill`` command that prints those
5
+ prompts on demand, an ``install`` command that materializes thin,
6
+ version-stamped files where the harness reads them, and a drift check that
7
+ tells the two apart. A :class:`Doc` is the same print-on-demand delivery for a
8
+ reference document a body loads mid-step, with nothing installed at all.
9
+ """
10
+
11
+ from pkgskills.artifacts import (
12
+ Check,
13
+ ForeignArtifactError,
14
+ InstallReport,
15
+ artifact_path,
16
+ check,
17
+ find_repo_root,
18
+ install,
19
+ installed_mode,
20
+ printing_mode,
21
+ )
22
+ from pkgskills.cli import register, typer_app
23
+ from pkgskills.frontmatter import Block, Frontmatter, find_block, split_frontmatter
24
+ from pkgskills.harness import CLAUDE_CODE, Harness, Kind
25
+ from pkgskills.host import Agent, Artifact, Doc, ExtraCheck, Host, Mode, Rule, Skill
26
+ from pkgskills.permissions import Level
27
+ from pkgskills.proc import LOCATION_ENV, run
28
+ from pkgskills.rendering import render, render_prompt
29
+ from pkgskills.spec import SPEC, Field, SkillSpec, SpecError, Violation
30
+
31
+ __all__ = [
32
+ "CLAUDE_CODE",
33
+ "LOCATION_ENV",
34
+ "SPEC",
35
+ "Agent",
36
+ "Artifact",
37
+ "Block",
38
+ "Check",
39
+ "Doc",
40
+ "ExtraCheck",
41
+ "Field",
42
+ "ForeignArtifactError",
43
+ "Frontmatter",
44
+ "Harness",
45
+ "Host",
46
+ "InstallReport",
47
+ "Kind",
48
+ "Level",
49
+ "Mode",
50
+ "Rule",
51
+ "Skill",
52
+ "SkillSpec",
53
+ "SpecError",
54
+ "Violation",
55
+ "artifact_path",
56
+ "check",
57
+ "find_block",
58
+ "find_repo_root",
59
+ "install",
60
+ "installed_mode",
61
+ "printing_mode",
62
+ "register",
63
+ "render",
64
+ "render_prompt",
65
+ "run",
66
+ "split_frontmatter",
67
+ "typer_app",
68
+ ]
pkgskills/artifacts.py ADDED
@@ -0,0 +1,420 @@
1
+ """Materialize, locate, and drift-check generated artifacts.
2
+
3
+ Two install modes, both first-class:
4
+
5
+ * **global**: one copy under ``$HOME`` serves every repository; the host CLI
6
+ is on ``PATH`` and invoked bare.
7
+ * **local**: the copy lives under the repository root and the CLI is invoked
8
+ through ``uv run`` (or the host's ``local_prefix``).
9
+
10
+ A file's mode is the one its location implies. A copy rendered for one mode
11
+ and carried to the other location reads as drifted, because the commands
12
+ embedded in it are wrong where it sits.
13
+
14
+ Which of the two a given host uses is the host's to declare: everything here
15
+ walks :attr:`Host.modes <pkgskills.host.Host.modes>` rather than both, so a host that
16
+ supports one mode is never checked at, or written to, the other's location.
17
+
18
+ Every write is guarded. A path occupied by anything that is not a plain file
19
+ this host generated (a hand-written file, a symlink, a directory, another
20
+ package's stamp) is *foreign* and is never replaced without ``force``. The
21
+ guard runs for every artifact before the first write, so a refused rule never
22
+ leaves a half-installed skill behind.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Literal
30
+
31
+ from pkgskills.harness import Harness
32
+ from pkgskills.host import Artifact, Host, Mode, Skill
33
+ from pkgskills.rendering import render
34
+ from pkgskills.stamp import is_stamped, mask_versions, stamped_by, stamped_mode
35
+
36
+ Status = Literal["ok", "drifted", "stale", "missing", "foreign"]
37
+
38
+
39
+ class ForeignArtifactError(Exception):
40
+ """A write was refused because the target is not ours to replace."""
41
+
42
+ def __init__(self, path: Path, reason: str) -> None:
43
+ super().__init__(f"{path}: {reason}")
44
+ self.path = path
45
+ self.reason = reason
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Check:
50
+ """The drift verdict for one artifact at one location."""
51
+
52
+ artifact: Artifact
53
+ mode: Mode
54
+ path: Path
55
+ status: Status
56
+ reason: str = ""
57
+
58
+ @property
59
+ def ok(self) -> bool:
60
+ return self.status == "ok"
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class InstallReport:
65
+ """What an install did, for the CLI to narrate and hooks to act on."""
66
+
67
+ host: Host
68
+ mode: Mode
69
+ root: Path
70
+ written: tuple[Path, ...] = field(default_factory=tuple)
71
+ removed: tuple[Path, ...] = field(default_factory=tuple)
72
+ shadowed: tuple[Path, ...] = field(default_factory=tuple)
73
+
74
+
75
+ # -- locations ---------------------------------------------------------------
76
+
77
+
78
+ def global_base() -> Path:
79
+ """The base of every global artifact: the user's home directory.
80
+
81
+ A function rather than a constant so importing the package never resolves
82
+ the home directory, which raises where none can be determined.
83
+ """
84
+ return Path.home()
85
+
86
+
87
+ def find_repo_root(start: Path | None = None, harness: Harness | None = None) -> Path:
88
+ """The nearest directory at or above ``start`` that looks like a repo root.
89
+
90
+ A repo root holds ``.git`` or the harness's config directory. A local
91
+ install must target the root the harness loads from, not whatever
92
+ subdirectory the command ran in. Falls back to ``start`` itself.
93
+
94
+ Deliberately a filesystem walk, never a question put to git: an ambient
95
+ ``GIT_DIR`` would otherwise name a repository the user is not looking at.
96
+ :mod:`pkgskills.proc` keeps the same posture on the write side.
97
+ """
98
+ base = (start or Path.cwd()).resolve()
99
+ marker = harness.config_dir if harness else None
100
+ for candidate in (base, *base.parents):
101
+ if (candidate / ".git").exists():
102
+ return candidate
103
+ if marker and (candidate / marker).is_dir():
104
+ return candidate
105
+ return base
106
+
107
+
108
+ def artifact_path(host: Host, art: Artifact, mode: Mode, root: Path) -> Path:
109
+ """Where ``art`` lives for ``mode``: under ``$HOME`` or under ``root``."""
110
+ base = global_base() if mode == "global" else root
111
+ return base / host.harness.relative_path(art.kind, art.name)
112
+
113
+
114
+ def occupied(path: Path) -> bool:
115
+ """True when something sits at ``path``, a dangling symlink included."""
116
+ return path.is_symlink() or path.exists()
117
+
118
+
119
+ def read_plain(path: Path) -> str | None:
120
+ """The text of a readable plain file, else ``None``. Never raises."""
121
+ if path.is_symlink() or not path.is_file():
122
+ return None
123
+ try:
124
+ return path.read_text(encoding="utf-8")
125
+ except (OSError, UnicodeDecodeError):
126
+ return None
127
+
128
+
129
+ def is_generated(path: Path, host: Host) -> bool:
130
+ """True only for a plain file that ``host`` generated."""
131
+ text = read_plain(path)
132
+ return text is not None and is_stamped(text, host)
133
+
134
+
135
+ # -- checking ----------------------------------------------------------------
136
+
137
+
138
+ def classify(path: Path, host: Host, expected: str, mode: Mode) -> tuple[Status, str]:
139
+ """Judge what sits at ``path`` against ``expected``, the current render.
140
+
141
+ A file that cannot be read (bad permissions, not UTF-8) folds into
142
+ ``foreign``, and the CLI points at ``--force``. That is honest only because
143
+ every write here is wholesale: ``--force`` genuinely fixes it. The rule the
144
+ status set obeys is that **a status must not imply a remedy the tool cannot
145
+ perform** — so an artifact kind that is *edited in place* rather than
146
+ rewritten (appending or amending one line of a file the host does not own)
147
+ needs its own ``unreadable`` status from the start. There, an installer that
148
+ cannot read the file refuses to write it, and both ``foreign`` and
149
+ ``missing`` would send the user in a circle.
150
+ """
151
+ if not occupied(path):
152
+ return "missing", "not installed"
153
+ if path.is_symlink():
154
+ return "foreign", "a symlink, not a plain file"
155
+ if path.is_dir():
156
+ return "foreign", "a directory, not a file"
157
+ text = read_plain(path)
158
+ if text is None:
159
+ return "foreign", "unreadable or not UTF-8"
160
+ if not is_stamped(text, host):
161
+ other = stamped_by(text)
162
+ if other:
163
+ return "foreign", f"generated by {other}, not {host.dist}"
164
+ return "foreign", "no stamp; hand-written or from an older release"
165
+ if mask_versions(text, host) == mask_versions(expected, host):
166
+ return "ok", ""
167
+ recorded = stamped_mode(text, host)
168
+ if recorded is not None and recorded != mode:
169
+ return "drifted", f"rendered for mode={recorded}, installed where mode={mode}"
170
+ return "drifted", "content differs from the current render"
171
+
172
+
173
+ def check_artifact(
174
+ host: Host,
175
+ art: Artifact,
176
+ mode: Mode,
177
+ root: Path,
178
+ *,
179
+ installed: Mode | None | Literal["auto"] = "auto",
180
+ ) -> Check:
181
+ """The drift verdict for ``art`` at its ``mode`` location.
182
+
183
+ A copy is ``stale`` when a resolved global install has superseded it and the
184
+ harness loads both — see :func:`stale_local`. Content correctness is not the
185
+ question there; the file's continued existence is, so the verdict outranks
186
+ both ``ok`` *and* ``drifted``. Rewriting a drifted leftover would only
187
+ recreate the file the remedy asks the user to remove.
188
+
189
+ ``installed`` is :func:`installed_mode`'s answer, which is one fact per
190
+ check run rather than per row; :func:`check` computes it once and passes it
191
+ down. The default re-derives it, so a lone call still works.
192
+ """
193
+ path = artifact_path(host, art, mode, root)
194
+ status, reason = classify(path, host, render(host, art, mode), mode)
195
+ if status in ("ok", "drifted") and stale_local(
196
+ host, art, mode, root, installed=installed
197
+ ):
198
+ return Check(
199
+ art,
200
+ mode,
201
+ path,
202
+ "stale",
203
+ "superseded by the global copy but still loaded; remove it",
204
+ )
205
+ return Check(art, mode, path, status, reason)
206
+
207
+
208
+ def check(host: Host, root: Path, mode: Mode | None = None) -> list[Check]:
209
+ """Drift for every artifact.
210
+
211
+ With ``mode`` given, each artifact is judged at that one location. Without
212
+ it, every occupied location the host supports is judged, in the host's
213
+ declared mode order (global first by default), because the harness loads
214
+ rules and agents from both at once and a stale
215
+ copy at either is real drift. An artifact present at neither location
216
+ reports ``missing`` once, against its default mode's path.
217
+ """
218
+ # One fact for the whole run: which mode an existing install resolves to.
219
+ # Re-deriving it per row would re-walk the filesystem for every artifact.
220
+ installed = installed_mode(host, root)
221
+ results: list[Check] = []
222
+ for art in host.artifacts:
223
+ if mode is not None:
224
+ results.append(check_artifact(host, art, mode, root, installed=installed))
225
+ continue
226
+ found = [
227
+ check_artifact(host, art, m, root, installed=installed)
228
+ for m in host.modes
229
+ if occupied(artifact_path(host, art, m, root))
230
+ ]
231
+ results.extend(
232
+ found
233
+ or [check_artifact(host, art, host.default_mode, root, installed=installed)]
234
+ )
235
+ return results
236
+
237
+
238
+ def installed_mode(host: Host, root: Path) -> Mode | None:
239
+ """The mode an existing install resolves to, or ``None``.
240
+
241
+ Modes are tried in the host's declared order, so the host's preference
242
+ decides which of two coexisting installs is the one being used.
243
+
244
+ Used to render printed bodies with the prefix the installed stub uses, so
245
+ what the model reads agrees with the commands it was told to run. Skills
246
+ decide it whenever the host ships any, because the stub is what carries
247
+ those commands; a host that ships none falls back to its other artifacts so
248
+ the answer stays grounded in what is actually on disk.
249
+
250
+ ``None`` means *nothing is installed* and is deliberately not folded into a
251
+ default here: callers that need a mode to print with substitute the host's
252
+ :attr:`~pkgskills.host.Host.default_mode` themselves, while callers asking "has
253
+ this repo been pinned to global?" need the difference.
254
+ """
255
+ for art in host.skills or host.artifacts:
256
+ for mode in host.modes:
257
+ if occupied(artifact_path(host, art, mode, root)):
258
+ return mode
259
+ return None
260
+
261
+
262
+ def printing_mode(host: Host, root: Path) -> Mode:
263
+ """The mode a *printed* body should render ``{cli}`` for.
264
+
265
+ :func:`installed_mode` when anything is installed, the host's
266
+ :attr:`~pkgskills.host.Host.default_mode` when nothing is — which is the whole
267
+ of the "which prefix do I print?" decision, exported because a host that
268
+ keeps a print command of its own has to make it the same way ``skill`` and
269
+ ``doc`` do or the commands it prints will not run.
270
+ """
271
+ return installed_mode(host, root) or host.default_mode
272
+
273
+
274
+ def stale_local(
275
+ host: Host,
276
+ art: Artifact,
277
+ mode: Mode,
278
+ root: Path,
279
+ *,
280
+ installed: Mode | None | Literal["auto"] = "auto",
281
+ ) -> bool:
282
+ """True when a local copy of ``art`` is live leftovers from before a switch.
283
+
284
+ A resolved global install serves this repo, yet a per-repo copy of a kind
285
+ the harness loads from *both* bases is still sitting there — so it is in
286
+ context right now, matching content or not. The remedy is to remove it, not
287
+ to regenerate it.
288
+
289
+ Three guards keep the verdict honest:
290
+
291
+ * Only ``local`` rows, and only for a host that has a global mode at all —
292
+ a local-only host can have nothing supersede its per-repo copy. The
293
+ asymmetry is deliberate: a *global* copy present during a local install
294
+ is shared infrastructure serving every other repository, never this
295
+ repo's leftover, and is never flagged.
296
+ * Only a genuinely resolved global install counts, so a local-only copy in
297
+ a repo with no global install still reads ``ok``.
298
+ * Only when the two paths differ, which they do not when the repo root is
299
+ ``$HOME`` — there is one file there, not a leftover second one.
300
+
301
+ ``installed`` lets a caller judging many artifacts hand in
302
+ :func:`installed_mode`'s answer instead of paying for it once per row.
303
+ """
304
+ if mode != "local" or host.harness.shadows(art.kind):
305
+ return False
306
+ if not host.supports_mode("global"):
307
+ return False
308
+ resolved = installed_mode(host, root) if installed == "auto" else installed
309
+ if resolved != "global":
310
+ return False
311
+ return artifact_path(host, art, "local", root) != artifact_path(
312
+ host, art, "global", root
313
+ )
314
+
315
+
316
+ def shadowed_skills(host: Host, root: Path) -> list[Path]:
317
+ """Local skill stubs that a global copy of the same skill shadows.
318
+
319
+ Under Claude Code's precedence the global skill wins, so a per-repo stub
320
+ with the same name is inert. Empty when the repo root is ``$HOME``, where
321
+ the two paths coincide and there is really only one file, and empty for a
322
+ host with no global mode, which never puts a stub there to shadow with.
323
+ """
324
+ if not host.supports_mode("global"):
325
+ return []
326
+ out: list[Path] = []
327
+ for skill in host.skills:
328
+ glob = artifact_path(host, skill, "global", root)
329
+ local = artifact_path(host, skill, "local", root)
330
+ if glob != local and occupied(glob) and occupied(local):
331
+ out.append(local)
332
+ return out
333
+
334
+
335
+ # -- writing -----------------------------------------------------------------
336
+
337
+
338
+ def guard(host: Host, root: Path, mode: Mode, *, force: bool) -> None:
339
+ """Refuse the install if any target is foreign and ``force`` is off."""
340
+ if force:
341
+ return
342
+ for art in host.artifacts:
343
+ path = artifact_path(host, art, mode, root)
344
+ if occupied(path) and not is_generated(path, host):
345
+ status, reason = classify(path, host, "", mode)
346
+ raise ForeignArtifactError(
347
+ path, reason if status == "foreign" else "foreign"
348
+ )
349
+
350
+
351
+ def write_artifact(host: Host, art: Artifact, mode: Mode, root: Path) -> Path:
352
+ """Write ``art``'s render for ``mode``, creating parents as needed.
353
+
354
+ A symlink at the target, dangling or not, is replaced with a plain file
355
+ rather than written through, so a write can never reach outside the tree
356
+ it was aimed at.
357
+ """
358
+ path = artifact_path(host, art, mode, root)
359
+ path.parent.mkdir(parents=True, exist_ok=True)
360
+ if path.is_symlink():
361
+ path.unlink()
362
+ path.write_text(render(host, art, mode), encoding="utf-8")
363
+ return path
364
+
365
+
366
+ def remove_stale_local(host: Host, root: Path) -> list[Path]:
367
+ """Drop per-repo copies that a fresh global install supersedes.
368
+
369
+ Only runs after a global install, never removes anything the host did not
370
+ generate, and never touches a path that coincides with the global one.
371
+ The reverse cleanup is deliberately absent: a local install never deletes
372
+ the global copy that serves every other repository.
373
+ """
374
+ removed: list[Path] = []
375
+ for art in host.artifacts:
376
+ local = artifact_path(host, art, "local", root)
377
+ if local == artifact_path(host, art, "global", root):
378
+ continue
379
+ if is_generated(local, host):
380
+ local.unlink()
381
+ removed.append(local)
382
+ return removed
383
+
384
+
385
+ def install(
386
+ host: Host, root: Path, mode: Mode, *, force: bool = False
387
+ ) -> InstallReport:
388
+ """Write every artifact for ``mode`` and run the host's follow-up hook.
389
+
390
+ Raises :class:`ForeignArtifactError` before writing anything when a target
391
+ is not ours and ``force`` is off, and ``ValueError`` when ``mode`` is not
392
+ one the host supports — a local-only host must not be written under
393
+ ``$HOME`` by a caller that bypassed the CLI's own check.
394
+ """
395
+ if not host.supports_mode(mode):
396
+ raise ValueError(
397
+ f"{host.dist} does not install in {mode} mode; "
398
+ f"it supports: {', '.join(host.modes)}"
399
+ )
400
+ guard(host, root, mode, force=force)
401
+ written = tuple(write_artifact(host, art, mode, root) for art in host.artifacts)
402
+ removed: tuple[Path, ...] = ()
403
+ shadowed: tuple[Path, ...] = ()
404
+ if mode == "global":
405
+ removed = tuple(remove_stale_local(host, root))
406
+ else:
407
+ shadowed = tuple(shadowed_skills(host, root))
408
+ report = InstallReport(host, mode, root, written, removed, shadowed)
409
+ if host.after_install is not None:
410
+ host.after_install(report)
411
+ return report
412
+
413
+
414
+ def skill_stub_paths(host: Host, root: Path) -> list[tuple[Skill, Mode, Path]]:
415
+ """Every skill stub location, for callers that narrate state."""
416
+ return [
417
+ (skill, mode, artifact_path(host, skill, mode, root))
418
+ for skill in host.skills
419
+ for mode in host.modes
420
+ ]