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 +68 -0
- pkgskills/artifacts.py +420 -0
- pkgskills/cli.py +495 -0
- pkgskills/frontmatter.py +199 -0
- pkgskills/harness.py +87 -0
- pkgskills/host.py +481 -0
- pkgskills/permissions.py +223 -0
- pkgskills/proc.py +78 -0
- pkgskills/py.typed +0 -0
- pkgskills/rendering.py +209 -0
- pkgskills/spec.py +499 -0
- pkgskills/stamp.py +129 -0
- pkgskills/testing.py +301 -0
- pkgskills-0.2.0.dist-info/METADATA +370 -0
- pkgskills-0.2.0.dist-info/RECORD +18 -0
- pkgskills-0.2.0.dist-info/WHEEL +4 -0
- pkgskills-0.2.0.dist-info/entry_points.txt +2 -0
- pkgskills-0.2.0.dist-info/licenses/LICENSE +22 -0
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
|
+
]
|