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.
- skillmate/__init__.py +3 -0
- skillmate/__main__.py +3 -0
- skillmate/adapters.py +169 -0
- skillmate/checkout.py +66 -0
- skillmate/cli.py +301 -0
- skillmate/content.py +124 -0
- skillmate/distribution.py +216 -0
- skillmate/installer.py +301 -0
- skillmate/opencode_config.py +404 -0
- skillmate/py.typed +0 -0
- skillmate/safety.py +298 -0
- skillmate-12.0.1.dev147.dist-info/METADATA +23 -0
- skillmate-12.0.1.dev147.dist-info/RECORD +15 -0
- skillmate-12.0.1.dev147.dist-info/WHEEL +4 -0
- skillmate-12.0.1.dev147.dist-info/entry_points.txt +2 -0
|
@@ -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)
|