skillmate 12.0.1.dev147__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.
@@ -0,0 +1,216 @@
1
+ """The portable distribution model and its sources.
2
+
3
+ A distribution is a directory (remote base URL or local path) holding an
4
+ ``index.json`` in the published Pages schema and content-addressed skill
5
+ archives under ``archives/sha256/``. Sources never fall back to each
6
+ other: an unreachable, malformed, or unpinned source is a loud error.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import io
13
+ import json
14
+ import tarfile
15
+ import urllib.error
16
+ import urllib.request
17
+ from dataclasses import dataclass
18
+ from pathlib import Path, PurePosixPath
19
+ from typing import TYPE_CHECKING
20
+ from urllib.parse import urljoin
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Callable, Mapping, Sequence
24
+
25
+ INDEX_SCHEMA = "@kisev/skills/index/v1"
26
+ DEFAULT_INDEX_URL = "https://kisev.github.io/skills/index.json"
27
+
28
+
29
+ class DistributionError(Exception):
30
+ """An unreachable, malformed, or integrity-failing distribution source."""
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class SkillEntry:
35
+ """One skill in the distribution index."""
36
+
37
+ name: str
38
+ archive: str
39
+ sha256: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Distribution:
44
+ """A parsed distribution index."""
45
+
46
+ package: str
47
+ version: str
48
+ source_revision: str
49
+ skills: Mapping[str, SkillEntry]
50
+
51
+ def require_version(self, pin: str) -> None:
52
+ """Fail loudly unless the distribution carries exactly the pinned version."""
53
+ if self.version != pin:
54
+ raise DistributionError(
55
+ f"distribution version {self.version} does not match the pinned {pin}"
56
+ )
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ArchiveFile:
61
+ """One regular file extracted from a skill archive, as bytes."""
62
+
63
+ path: str
64
+ data: bytes
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class SkillArchive:
69
+ """A verified, fully extracted skill archive."""
70
+
71
+ name: str
72
+ sha256: str
73
+ files: tuple[ArchiveFile, ...]
74
+
75
+
76
+ def parse_index(text: str) -> Distribution:
77
+ """Parse and validate an index document against the published schema."""
78
+ try:
79
+ document = json.loads(text)
80
+ except json.JSONDecodeError as error:
81
+ raise DistributionError(f"index is not valid JSON: {error}") from error
82
+ if not isinstance(document, dict):
83
+ raise DistributionError("index must be a JSON object")
84
+ schema = document.get("schema")
85
+ if schema != INDEX_SCHEMA:
86
+ raise DistributionError(f"unsupported index schema {schema!r}, expected {INDEX_SCHEMA!r}")
87
+ package = document.get("package")
88
+ version = document.get("version")
89
+ revision = document.get("source_revision")
90
+ entries = document.get("skills")
91
+ if (
92
+ not isinstance(package, str)
93
+ or not isinstance(version, str)
94
+ or not isinstance(revision, str)
95
+ ):
96
+ raise DistributionError("index fields package, version, source_revision must be strings")
97
+ if not isinstance(entries, list):
98
+ raise DistributionError("index field skills must be a list")
99
+ skills: dict[str, SkillEntry] = {}
100
+ for entry in entries:
101
+ if not isinstance(entry, dict):
102
+ raise DistributionError("each skill entry must be a JSON object")
103
+ name = entry.get("name")
104
+ archive = entry.get("archive")
105
+ sha256 = entry.get("sha256")
106
+ if not (
107
+ isinstance(name, str)
108
+ and isinstance(archive, str)
109
+ and isinstance(sha256, str)
110
+ and name
111
+ and archive
112
+ and len(sha256) == 64
113
+ ):
114
+ raise DistributionError(f"skill entry {name!r} has invalid name, archive, or sha256")
115
+ if name in skills:
116
+ raise DistributionError(f"duplicate skill name {name!r} in index")
117
+ archive_path = PurePosixPath(archive)
118
+ if archive_path.is_absolute() or ".." in archive_path.parts:
119
+ raise DistributionError(f"skill entry {name!r} has an escaping archive path")
120
+ skills[name] = SkillEntry(name=name, archive=archive, sha256=sha256)
121
+ return Distribution(package=package, version=version, source_revision=revision, skills=skills)
122
+
123
+
124
+ def extract_archive(name: str, sha256: str, blob: bytes) -> SkillArchive:
125
+ """Verify an archive's digest and extract every member in memory.
126
+
127
+ Only regular files under the archive root are accepted; absolute
128
+ paths, ``..`` segments, and non-regular members are rejected.
129
+ """
130
+ digest = hashlib.sha256(blob).hexdigest()
131
+ if digest != sha256:
132
+ raise DistributionError(
133
+ f"archive for {name!r} failed integrity: expected {sha256}, got {digest}"
134
+ )
135
+ files: list[ArchiveFile] = []
136
+ seen: set[str] = set()
137
+ try:
138
+ with tarfile.open(fileobj=io.BytesIO(blob), mode="r:gz") as archive:
139
+ members: Sequence[tarfile.TarInfo] = archive.getmembers()
140
+ for member in members:
141
+ if member.isdir():
142
+ continue
143
+ if not member.isfile():
144
+ raise DistributionError(
145
+ f"archive for {name!r} contains a non-regular member {member.name!r}"
146
+ )
147
+ member_path = PurePosixPath(member.name)
148
+ if member_path.is_absolute() or ".." in member_path.parts or not member.name:
149
+ raise DistributionError(
150
+ f"archive for {name!r} contains an escaping member path {member.name!r}"
151
+ )
152
+ if member.name in seen:
153
+ raise DistributionError(
154
+ f"archive for {name!r} contains duplicate member {member.name!r}"
155
+ )
156
+ extracted = archive.extractfile(member)
157
+ if extracted is None: # pragma: no cover - guarded by isfile
158
+ raise DistributionError(f"member {member.name!r} of {name!r} unreadable")
159
+ seen.add(member.name)
160
+ files.append(ArchiveFile(path=member.name, data=extracted.read()))
161
+ except tarfile.TarError as error:
162
+ raise DistributionError(
163
+ f"archive for {name!r} is not a readable tar.gz: {error}"
164
+ ) from error
165
+ return SkillArchive(name=name, sha256=sha256, files=tuple(files))
166
+
167
+
168
+ def _default_fetch(url: str) -> bytes:
169
+ try:
170
+ with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310
171
+ blob: bytes = response.read()
172
+ return blob
173
+ except (urllib.error.URLError, OSError) as error:
174
+ raise DistributionError(f"cannot fetch {url}: {error}") from error
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class RemoteSource:
179
+ """The primary source: the published Pages distribution over HTTP(S).
180
+
181
+ ``fetch`` exists so tests can serve the same layout offline.
182
+ """
183
+
184
+ index_url: str = DEFAULT_INDEX_URL
185
+ fetch: Callable[[str], bytes] = _default_fetch
186
+
187
+ def index(self) -> Distribution:
188
+ return parse_index(self.fetch(self.index_url).decode("utf-8"))
189
+
190
+ def archive(self, entry: SkillEntry) -> SkillArchive:
191
+ blob = self.fetch(urljoin(self.index_url, entry.archive))
192
+ return extract_archive(entry.name, entry.sha256, blob)
193
+
194
+
195
+ @dataclass(frozen=True)
196
+ class LocalSource:
197
+ """A distribution directory: the layout of a checkout build or a mirror."""
198
+
199
+ base: Path
200
+
201
+ def index(self) -> Distribution:
202
+ index_path = self.base / "index.json"
203
+ try:
204
+ return parse_index(index_path.read_text(encoding="utf-8"))
205
+ except OSError as error:
206
+ raise DistributionError(f"cannot read {index_path}: {error}") from error
207
+
208
+ def archive(self, entry: SkillEntry) -> SkillArchive:
209
+ archive_path = self.base / entry.archive
210
+ if not archive_path.is_relative_to(self.base):
211
+ raise DistributionError(f"archive path {entry.archive!r} escapes the source directory")
212
+ try:
213
+ blob = archive_path.read_bytes()
214
+ except OSError as error:
215
+ raise DistributionError(f"cannot read {archive_path}: {error}") from error
216
+ return extract_archive(entry.name, entry.sha256, blob)
skillmate/installer.py ADDED
@@ -0,0 +1,301 @@
1
+ """Lifecycle orchestration: sources, adapters, and plans per host.
2
+
3
+ The CLI layer resolves a source, asks the adapters for each host's desired
4
+ state, and turns it into safety-model plans. Nothing here writes: plans are
5
+ executed by :mod:`skillmate.safety` after confirmation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Callable
15
+ from pathlib import Path
16
+
17
+ from skillmate.adapters import ADAPTERS, HostAdapter, install_notices
18
+ from skillmate.checkout import CheckoutSource
19
+ from skillmate.content import SkillContent, skill_content
20
+ from skillmate.distribution import (
21
+ Distribution,
22
+ DistributionError,
23
+ RemoteSource,
24
+ SkillArchive,
25
+ SkillEntry,
26
+ )
27
+ from skillmate.safety import (
28
+ Plan,
29
+ build_install_plan,
30
+ build_remove_plan,
31
+ disk_digest,
32
+ load_ownership,
33
+ )
34
+
35
+ Scope = str # "project" | "global"
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SourceSelection:
40
+ """A resolved content source: the index plus an archive reader."""
41
+
42
+ distribution: Distribution
43
+ archive: Callable[[SkillEntry], SkillArchive]
44
+ label: str
45
+
46
+ def contents(self, names: tuple[str, ...] | None = None) -> tuple[SkillContent, ...]:
47
+ selected = (
48
+ [self.distribution.skills[name] for name in names]
49
+ if names
50
+ else list(self.distribution.skills.values())
51
+ )
52
+ return tuple(skill_content(self.archive(entry)) for entry in selected)
53
+
54
+
55
+ def resolve_source(
56
+ from_checkout: Path | None, index_url: str, target_version: str | None
57
+ ) -> SourceSelection:
58
+ """Resolve the one source in play; no source falls back to another."""
59
+ if from_checkout is not None:
60
+ distribution, local = CheckoutSource(checkout=from_checkout).resolve()
61
+ if target_version is not None:
62
+ distribution.require_version(target_version)
63
+ return SourceSelection(distribution=distribution, archive=local.archive, label="checkout")
64
+ remote = RemoteSource(index_url=index_url)
65
+ distribution = remote.index()
66
+ if target_version is not None:
67
+ distribution.require_version(target_version)
68
+ return SourceSelection(distribution=distribution, archive=remote.archive, label="distribution")
69
+
70
+
71
+ def host_root(adapter: HostAdapter, scope: str, project: Path, home: Path) -> Path:
72
+ """The directory a host reads (for example ``<project>/.claude``)."""
73
+ if scope == "global":
74
+ return home / adapter.skills_global.parent
75
+ return project / adapter.skills_project.parent
76
+
77
+
78
+ def _adapters(hosts: tuple[str, ...]) -> tuple[HostAdapter, ...]:
79
+ unknown = [host for host in hosts if host not in ADAPTERS]
80
+ if unknown:
81
+ raise DistributionError(f"unknown hosts: {', '.join(unknown)}")
82
+ return tuple(ADAPTERS[host] for host in hosts)
83
+
84
+
85
+ def desired_state(
86
+ adapter: HostAdapter, contents: tuple[SkillContent, ...], known_names: frozenset[str]
87
+ ) -> dict[str, bytes]:
88
+ """The full desired file set of one host: skills plus command surfaces."""
89
+ desired: dict[str, bytes] = {}
90
+ for content in contents:
91
+ for file in adapter.materialize(content, known_names):
92
+ desired[f"skills/{content.name}/{file.path}"] = file.data
93
+ document = adapter.command_document(content)
94
+ if document is not None:
95
+ desired[f"commands/{content.name}.md"] = document.encode("utf-8")
96
+ return desired
97
+
98
+
99
+ def plan_install(
100
+ selection: SourceSelection,
101
+ hosts: tuple[str, ...],
102
+ scope: str,
103
+ project: Path,
104
+ home: Path,
105
+ skill_names: tuple[str, ...] | None = None,
106
+ ) -> tuple[Plan, ...]:
107
+ """Build one install/update plan per selected host."""
108
+ names = tuple(sorted(selection.distribution.skills)) if skill_names is None else skill_names
109
+ missing = [name for name in names if name not in selection.distribution.skills]
110
+ if missing:
111
+ raise DistributionError(f"unknown skills: {', '.join(missing)}")
112
+ contents = selection.contents(names)
113
+ known = frozenset(selection.distribution.skills)
114
+ notices = install_notices(hosts)
115
+ plans: list[Plan] = []
116
+ for adapter in _adapters(hosts):
117
+ root = host_root(adapter, scope, project, home)
118
+ desired = desired_state(adapter, contents, known)
119
+ plans.append(
120
+ build_install_plan(
121
+ root,
122
+ desired,
123
+ load_ownership(root),
124
+ selection.distribution.version,
125
+ selection.label,
126
+ notices=notices,
127
+ )
128
+ )
129
+ return tuple(plans)
130
+
131
+
132
+ def plan_remove(
133
+ hosts: tuple[str, ...], scope: str, project: Path, home: Path
134
+ ) -> tuple[tuple[str, Plan | None], ...]:
135
+ """Build one removal plan per selected host; ``None`` marks no install."""
136
+ results: list[tuple[str, Plan | None]] = []
137
+ for adapter in _adapters(hosts):
138
+ root = host_root(adapter, scope, project, home)
139
+ ownership = load_ownership(root)
140
+ if ownership is None:
141
+ results.append((adapter.host, None))
142
+ continue
143
+ results.append((adapter.host, build_remove_plan(root, ownership)))
144
+ return tuple(results)
145
+
146
+
147
+ @dataclass(frozen=True)
148
+ class Diagnosis:
149
+ """One doctor finding: what is wrong, where, and how to repair it."""
150
+
151
+ host: str
152
+ scope: str
153
+ kind: str
154
+ detail: str
155
+ repair: str
156
+
157
+
158
+ @dataclass(frozen=True)
159
+ class InstallRecord:
160
+ """One installed host and scope, as status reports it."""
161
+
162
+ host: str
163
+ scope: str
164
+ version: str
165
+ source: str
166
+ files: int
167
+
168
+
169
+ def status(
170
+ hosts: tuple[str, ...], scope: str, project: Path, home: Path
171
+ ) -> tuple[InstallRecord, ...]:
172
+ """Read the installed versions per host and scope."""
173
+ records: list[InstallRecord] = []
174
+ for adapter in _adapters(hosts):
175
+ root = host_root(adapter, scope, project, home)
176
+ ownership = load_ownership(root)
177
+ if ownership is None:
178
+ continue
179
+ records.append(
180
+ InstallRecord(
181
+ host=adapter.host,
182
+ scope=scope,
183
+ version=ownership.distribution_version,
184
+ source=ownership.source,
185
+ files=len(ownership.files),
186
+ )
187
+ )
188
+ return tuple(records)
189
+
190
+
191
+ def doctor(
192
+ hosts: tuple[str, ...],
193
+ scope: str,
194
+ project: Path,
195
+ home: Path,
196
+ selection: SourceSelection | None,
197
+ ) -> tuple[Diagnosis, ...]:
198
+ """Diagnose each installation without writing anything.
199
+
200
+ Names missing files, version drift against the distribution, user
201
+ drift, stale ownership records, and foreign files occupying
202
+ distribution paths, each with its exact repair.
203
+ """
204
+ diagnoses: list[Diagnosis] = []
205
+ for adapter in _adapters(hosts):
206
+ root = host_root(adapter, scope, project, home)
207
+ ownership = load_ownership(root)
208
+ if ownership is None:
209
+ diagnoses.append(
210
+ Diagnosis(
211
+ host=adapter.host,
212
+ scope=scope,
213
+ kind="not-installed",
214
+ detail=f"no installation recorded under {root}",
215
+ repair="run skillmate install",
216
+ )
217
+ )
218
+ continue
219
+ desired = None
220
+ if selection is not None:
221
+ try:
222
+ contents = selection.contents()
223
+ known = frozenset(selection.distribution.skills)
224
+ desired = desired_state(adapter, contents, known)
225
+ except DistributionError as error:
226
+ diagnoses.append(
227
+ Diagnosis(
228
+ host=adapter.host,
229
+ scope=scope,
230
+ kind="source-unavailable",
231
+ detail=str(error),
232
+ repair="check network or pass --from-checkout",
233
+ )
234
+ )
235
+ for owned in ownership.files:
236
+ disk = disk_digest(root, owned.path)
237
+ if disk is None:
238
+ diagnoses.append(
239
+ Diagnosis(
240
+ host=adapter.host,
241
+ scope=scope,
242
+ kind="missing-file",
243
+ detail=owned.path,
244
+ repair=f"skillmate update --hosts {adapter.host}",
245
+ )
246
+ )
247
+ elif disk != owned.sha256:
248
+ diagnoses.append(
249
+ Diagnosis(
250
+ host=adapter.host,
251
+ scope=scope,
252
+ kind="drifted-file",
253
+ detail=f"{owned.path} differs from the recorded digest",
254
+ repair="restore it or reconfirm the change with skillmate update",
255
+ )
256
+ )
257
+ if (
258
+ selection is not None
259
+ and ownership.distribution_version != selection.distribution.version
260
+ ):
261
+ diagnoses.append(
262
+ Diagnosis(
263
+ host=adapter.host,
264
+ scope=scope,
265
+ kind="version-drift",
266
+ detail=(
267
+ f"installed {ownership.distribution_version}, "
268
+ f"distribution {selection.distribution.version}"
269
+ ),
270
+ repair=f"skillmate update --hosts {adapter.host}",
271
+ )
272
+ )
273
+ if desired is not None:
274
+ recorded = ownership.by_path()
275
+ stale = [path for path in recorded if path not in desired]
276
+ if stale:
277
+ diagnoses.append(
278
+ Diagnosis(
279
+ host=adapter.host,
280
+ scope=scope,
281
+ kind="stale-record",
282
+ detail=f"{len(stale)} owned files are no longer part of the distribution",
283
+ repair=f"skillmate update --hosts {adapter.host}",
284
+ )
285
+ )
286
+ for path in sorted(desired):
287
+ if path in recorded or disk_digest(root, path) is None:
288
+ continue
289
+ diagnoses.append(
290
+ Diagnosis(
291
+ host=adapter.host,
292
+ scope=scope,
293
+ kind="foreign-path",
294
+ detail=f"{path} is not owned by skillmate",
295
+ repair=(
296
+ "remove or move it away, then run "
297
+ f"skillmate update --hosts {adapter.host}"
298
+ ),
299
+ )
300
+ )
301
+ return tuple(diagnoses)