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,404 @@
1
+ """OpenCode configuration fragments, ported from the legacy config-setup contract.
2
+
3
+ The retired integration's behaviors that survive here (its REQ-F-514): only
4
+ absent preset values are added; user entries, comments, and formatting
5
+ outside the edit are preserved; an existing different value at a fragment
6
+ path is a conflict that is reported and never merged; repeated application
7
+ is a byte-identical no-op; every written document re-parses as JSONC.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import re
14
+ from dataclasses import dataclass
15
+
16
+ type JSONValue = str | int | float | bool | list[JSONValue] | dict[str, JSONValue] | None
17
+
18
+
19
+ class ConfigError(Exception):
20
+ """An unreadable configuration document or a malformed fragment."""
21
+
22
+
23
+ class ConfigConflictError(ConfigError):
24
+ """The document already carries a different value at a fragment path."""
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class FragmentEntry:
29
+ """One preset value: ``key`` inside the object at ``path`` (root when empty)."""
30
+
31
+ path: tuple[str, ...]
32
+ key: str
33
+ value: JSONValue
34
+
35
+ def label(self) -> str:
36
+ return ".".join((*self.path, self.key))
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class ConfigFragment:
41
+ """A named set of preset values applied together or not at all."""
42
+
43
+ name: str
44
+ entries: tuple[FragmentEntry, ...]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Evaluation:
49
+ """What applying a fragment to a document would do."""
50
+
51
+ operations: tuple[str, ...]
52
+ conflicts: tuple[str, ...]
53
+
54
+ @property
55
+ def clean(self) -> bool:
56
+ return not self.conflicts
57
+
58
+ @property
59
+ def changes_needed(self) -> bool:
60
+ return bool(self.operations)
61
+
62
+
63
+ def strip_comments(text: str) -> str:
64
+ """Blank out // and /* */ comments outside strings, preserving positions."""
65
+ output: list[str] = []
66
+ index = 0
67
+ length = len(text)
68
+ in_string = False
69
+ while index < length:
70
+ char = text[index]
71
+ if in_string:
72
+ output.append(char)
73
+ if char == "\\" and index + 1 < length:
74
+ output.append(text[index + 1])
75
+ index += 2
76
+ continue
77
+ if char == '"':
78
+ in_string = False
79
+ index += 1
80
+ continue
81
+ if char == '"':
82
+ in_string = True
83
+ output.append(char)
84
+ index += 1
85
+ continue
86
+ if char == "/" and index + 1 < length and text[index + 1] == "/":
87
+ while index < length and text[index] != "\n":
88
+ output.append(" ")
89
+ index += 1
90
+ continue
91
+ if char == "/" and index + 1 < length and text[index + 1] == "*":
92
+ end = text.find("*/", index + 2)
93
+ end = length if end == -1 else end + 2
94
+ for position in range(index, end):
95
+ output.append("\n" if text[position] == "\n" else " ")
96
+ index = end
97
+ continue
98
+ output.append(char)
99
+ index += 1
100
+ return "".join(output)
101
+
102
+
103
+ def parse_jsonc(text: str) -> JSONValue:
104
+ """Parse a JSONC document (comments and trailing commas tolerated)."""
105
+ stripped = strip_comments(text).strip()
106
+ stripped = re.sub(r",\s*([}\]])", r"\1", stripped)
107
+ if not stripped:
108
+ raise ConfigError("configuration document is empty")
109
+ try:
110
+ value: JSONValue = json.loads(stripped)
111
+ except json.JSONDecodeError as error:
112
+ raise ConfigError(f"configuration document is not valid JSONC: {error}") from error
113
+ return value
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class _Token:
118
+ kind: str # "string" | "literal" | "punct" | "comment" | "ws"
119
+ start: int
120
+ end: int
121
+ text: str = ""
122
+
123
+
124
+ def _tokenize(text: str) -> list[_Token]:
125
+ """Split JSONC text into tokens carrying original offsets."""
126
+ tokens: list[_Token] = []
127
+ index = 0
128
+ length = len(text)
129
+ while index < length:
130
+ char = text[index]
131
+ if char.isspace():
132
+ start = index
133
+ while index < length and text[index].isspace():
134
+ index += 1
135
+ tokens.append(_Token("ws", start, index))
136
+ continue
137
+ if char == '"':
138
+ start = index
139
+ index += 1
140
+ while index < length:
141
+ if text[index] == "\\":
142
+ index += 2
143
+ continue
144
+ if text[index] == '"':
145
+ index += 1
146
+ break
147
+ index += 1
148
+ tokens.append(_Token("string", start, index, text[start:index]))
149
+ continue
150
+ if char == "/" and index + 1 < length and text[index + 1] == "/":
151
+ start = index
152
+ while index < length and text[index] != "\n":
153
+ index += 1
154
+ tokens.append(_Token("comment", start, index))
155
+ continue
156
+ if char == "/" and index + 1 < length and text[index + 1] == "*":
157
+ start = index
158
+ end = text.find("*/", index + 2)
159
+ index = length if end == -1 else end + 2
160
+ tokens.append(_Token("comment", start, index))
161
+ continue
162
+ if char in "{}[],:":
163
+ tokens.append(_Token("punct", index, index + 1, char))
164
+ index += 1
165
+ continue
166
+ start = index
167
+ while index < length and not text[index].isspace() and text[index] not in '{}[],:"/':
168
+ index += 1
169
+ tokens.append(_Token("literal", start, index, text[start:index]))
170
+ return tokens
171
+
172
+
173
+ @dataclass
174
+ class _Frame:
175
+ kind: str # "object" | "array"
176
+ path: tuple[str, ...]
177
+ open_start: int = 0
178
+ pending_key: str | None = None
179
+ first_child: int | None = None
180
+ last_child_end: int | None = None
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class _Span:
185
+ is_object: bool
186
+ close_offset: int
187
+ first_child: int | None
188
+ last_child_end: int | None
189
+
190
+
191
+ def _structure(tokens: list[_Token]) -> dict[tuple[str, ...], _Span]:
192
+ """Map every object and array path to its span in the original text."""
193
+ significant = [token for token in tokens if token.kind not in ("ws", "comment")]
194
+ spans: dict[tuple[str, ...], _Span] = {}
195
+ stack: list[_Frame] = []
196
+ index = 0
197
+ while index < len(significant):
198
+ token = significant[index]
199
+ frame = stack[-1] if stack else None
200
+ if token.text in ("{", "["):
201
+ if frame is None:
202
+ path: tuple[str, ...] = ()
203
+ elif frame.kind == "object":
204
+ if frame.pending_key is None:
205
+ raise ConfigError("object opens without a key where one is required")
206
+ path = (*frame.path, frame.pending_key)
207
+ frame.pending_key = None
208
+ else:
209
+ path = frame.path
210
+ stack.append(
211
+ _Frame(
212
+ kind="object" if token.text == "{" else "array",
213
+ path=path,
214
+ open_start=token.start,
215
+ )
216
+ )
217
+ index += 1
218
+ continue
219
+ if token.text in ("}", "]"):
220
+ closing = stack.pop()
221
+ spans[closing.path] = _Span(
222
+ is_object=closing.kind == "object",
223
+ close_offset=token.start,
224
+ first_child=closing.first_child,
225
+ last_child_end=closing.last_child_end,
226
+ )
227
+ parent = stack[-1] if stack else None
228
+ if parent is not None:
229
+ _note_child(parent, closing.open_start, token.end)
230
+ index += 1
231
+ continue
232
+ if frame is None:
233
+ index += 1
234
+ continue
235
+ if (
236
+ frame.kind == "object"
237
+ and token.kind == "string"
238
+ and index + 1 < len(significant)
239
+ and significant[index + 1].text == ":"
240
+ ):
241
+ frame.pending_key = _decode_string(token)
242
+ elif token.text not in {":", ","}:
243
+ _note_child(frame, token.start, token.end)
244
+ index += 1
245
+ return spans
246
+
247
+
248
+ def _note_child(frame: _Frame, start: int, end: int) -> None:
249
+ if frame.first_child is None:
250
+ frame.first_child = start
251
+ frame.last_child_end = end
252
+
253
+
254
+ def _decode_string(token: _Token) -> str:
255
+ try:
256
+ value = json.loads(token.text)
257
+ except json.JSONDecodeError as error: # pragma: no cover - tokenizer guarantees form
258
+ raise ConfigError(f"unreadable string token: {error}") from error
259
+ return value if isinstance(value, str) else ""
260
+
261
+
262
+ def _navigate(document: JSONValue, path: tuple[str, ...]) -> JSONValue | None:
263
+ current: JSONValue = document
264
+ for key in path:
265
+ if not isinstance(current, dict) or key not in current:
266
+ return None
267
+ current = current[key]
268
+ return current
269
+
270
+
271
+ def evaluate(text: str, fragment: ConfigFragment) -> Evaluation:
272
+ """Report the operations and conflicts a fragment has against a document."""
273
+ document = parse_jsonc(text)
274
+ if not isinstance(document, dict):
275
+ raise ConfigError("the configuration document root must be an object")
276
+ operations: list[str] = []
277
+ conflicts: list[str] = []
278
+ for entry in fragment.entries:
279
+ parent = _navigate(document, entry.path)
280
+ if parent is None:
281
+ operations.append(f"create {entry.label()}")
282
+ continue
283
+ if not isinstance(parent, dict):
284
+ raise ConfigError(f"fragment path {'.'.join(entry.path)} is not an object")
285
+ if entry.key in parent:
286
+ if parent[entry.key] != entry.value:
287
+ conflicts.append(f"{entry.label()}: present value differs from the fragment preset")
288
+ else:
289
+ operations.append(f"set {entry.label()}")
290
+ return Evaluation(operations=tuple(operations), conflicts=tuple(conflicts))
291
+
292
+
293
+ def _render_entry(entry: FragmentEntry) -> str:
294
+ return f'"{entry.key}": {json.dumps(entry.value, ensure_ascii=False)}'
295
+
296
+
297
+ def _indent_at(text: str, offset: int) -> str:
298
+ line_start = text.rfind("\n", 0, offset) + 1
299
+ indent = ""
300
+ for char in text[line_start:offset]:
301
+ if char not in " \t":
302
+ break
303
+ indent += char
304
+ return indent
305
+
306
+
307
+ def apply_fragment(text: str, fragment: ConfigFragment) -> str:
308
+ """Apply a fragment to a JSONC document, preserving everything else.
309
+
310
+ Conflicts abort with :class:`ConfigConflictError`; a document needing no
311
+ change comes back byte-identical. The comma after the last existing
312
+ value lands before that value's trailing comment, and new lines land
313
+ after it, so line comments stay attached to what they annotate.
314
+ """
315
+ evaluation = evaluate(text, fragment)
316
+ if evaluation.conflicts:
317
+ raise ConfigConflictError(
318
+ f"fragment {fragment.name!r} conflicts: " + "; ".join(evaluation.conflicts)
319
+ )
320
+ if not evaluation.operations:
321
+ return text
322
+
323
+ document = parse_jsonc(text)
324
+ tokens = _tokenize(text)
325
+ spans = _structure(tokens)
326
+ lines_by_container: dict[tuple[str, ...], list[str]] = {}
327
+ trees_by_container: dict[tuple[str, ...], dict[str, JSONValue]] = {}
328
+ for entry in fragment.entries:
329
+ parent = _navigate(document, entry.path)
330
+ if parent is None:
331
+ ancestor = _deepest_ancestor(spans, entry.path)
332
+ tree = trees_by_container.setdefault(ancestor, {})
333
+ node: dict[str, JSONValue] = tree
334
+ for key in entry.path[len(ancestor) :]:
335
+ existing = node.get(key)
336
+ child: dict[str, JSONValue] = existing if isinstance(existing, dict) else {}
337
+ node[key] = child
338
+ node = child
339
+ node[entry.key] = entry.value
340
+ elif not isinstance(parent, dict):
341
+ raise ConfigError(f"fragment path {'.'.join(entry.path)} is not an object")
342
+ elif entry.key not in parent:
343
+ lines_by_container.setdefault(entry.path, []).append(_render_entry(entry))
344
+ for ancestor, tree in trees_by_container.items():
345
+ for key, subtree in tree.items():
346
+ lines_by_container.setdefault(ancestor, []).append(
347
+ f"{json.dumps(key, ensure_ascii=False)}: {json.dumps(subtree, ensure_ascii=False)}"
348
+ )
349
+
350
+ insertions: list[tuple[int, str]] = []
351
+ for path, lines in lines_by_container.items():
352
+ span = spans.get(path)
353
+ if span is None:
354
+ raise ConfigError(f"fragment container {'.'.join(path)} is missing from the document")
355
+ if not span.is_object:
356
+ raise ConfigError(f"fragment container {'.'.join(path)} is not an object")
357
+ container_indent = _indent_at(text, span.close_offset)
358
+ child_indent = _indent_at(text, span.first_child or span.close_offset)
359
+ body = (",\n" + child_indent).join(lines)
360
+ if span.last_child_end is not None:
361
+ insertions.append((span.last_child_end, ","))
362
+ content_end = _content_end(tokens, span.last_child_end, span.close_offset)
363
+ insertions.append((content_end, f"\n{child_indent}{body}"))
364
+ else:
365
+ insertions.append((span.close_offset, f"\n{child_indent}{body}\n{container_indent}"))
366
+ # Descending offsets keep earlier spans valid; at equal offsets the
367
+ # comma lands closest to the anchor, so it ends up before the new lines.
368
+ ordered = sorted(
369
+ insertions,
370
+ key=lambda item: (item[0], 0 if item[1] == "," else 1),
371
+ reverse=True,
372
+ )
373
+ for offset, insertion in ordered:
374
+ text = text[:offset] + insertion + text[offset:]
375
+
376
+ result = parse_jsonc(text)
377
+ if not isinstance(result, dict):
378
+ raise ConfigError("merged configuration does not reparse as an object")
379
+ for entry in fragment.entries:
380
+ current = _navigate(result, entry.path)
381
+ if not isinstance(current, dict) or current.get(entry.key) != entry.value:
382
+ raise ConfigError(f"merged configuration lost the preset {entry.label()}")
383
+ return text
384
+
385
+
386
+ def _content_end(tokens: list[_Token], last_child_end: int, close_offset: int) -> int:
387
+ """End of the last content on the final line, including a trailing comment."""
388
+ end = last_child_end
389
+ for token in tokens:
390
+ if token.kind == "comment" and last_child_end <= token.start < close_offset:
391
+ end = max(end, token.end)
392
+ return end
393
+
394
+
395
+ def _deepest_ancestor(
396
+ spans: dict[tuple[str, ...], _Span], path: tuple[str, ...]
397
+ ) -> tuple[str, ...]:
398
+ """The longest prefix of ``path`` that exists as an object container."""
399
+ for length in range(len(path), -1, -1):
400
+ candidate = path[:length]
401
+ span = spans.get(candidate)
402
+ if span is not None and span.is_object:
403
+ return candidate
404
+ raise ConfigError(f"fragment path {'.'.join(path)} has no object ancestor in the document")
skillmate/py.typed ADDED
File without changes
skillmate/safety.py ADDED
@@ -0,0 +1,298 @@
1
+ """The safety model: preview, confirmation, ownership, collision, drift.
2
+
3
+ Every write path goes through one plan type. A plan is built from a desired
4
+ state and the recorded ownership; execution refuses to run unconfirmed, with
5
+ unresolved collisions, or with drifted files pending a drift decision. Files
6
+ skillmate does not own are never touched, and a file the user modified after
7
+ installation is never silently overwritten.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ from dataclasses import dataclass
16
+ from pathlib import Path, PurePosixPath
17
+
18
+ MANIFEST_SCHEMA = "skillmate-ownership/v1"
19
+ MANIFEST_RELATIVE = ".skillmate/manifest.json"
20
+
21
+
22
+ class SafetyError(Exception):
23
+ """A plan cannot be executed as asked."""
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class OwnedFile:
28
+ path: str
29
+ sha256: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Ownership:
34
+ """The recorded ownership of one host installation."""
35
+
36
+ distribution_version: str
37
+ source: str # "distribution" | "checkout"
38
+ files: tuple[OwnedFile, ...]
39
+
40
+ def by_path(self) -> dict[str, str]:
41
+ return {owned.path: owned.sha256 for owned in self.files}
42
+
43
+
44
+ def digest(data: bytes) -> str:
45
+ return hashlib.sha256(data).hexdigest()
46
+
47
+
48
+ def load_ownership(root: Path) -> Ownership | None:
49
+ """Read the ownership manifest of a host directory, if any."""
50
+ manifest = root / MANIFEST_RELATIVE
51
+ if not manifest.is_file():
52
+ return None
53
+ try:
54
+ document = json.loads(manifest.read_text(encoding="utf-8"))
55
+ except (OSError, json.JSONDecodeError) as error:
56
+ raise SafetyError(f"ownership manifest at {manifest} is unreadable: {error}") from error
57
+ if document.get("schema") != MANIFEST_SCHEMA:
58
+ raise SafetyError(f"ownership manifest at {manifest} has an unknown schema")
59
+ version = document.get("version")
60
+ source = document.get("source")
61
+ files = document.get("files")
62
+ if not isinstance(version, str) or not isinstance(source, str) or not isinstance(files, list):
63
+ raise SafetyError(f"ownership manifest at {manifest} is malformed")
64
+ owned: list[OwnedFile] = []
65
+ for entry in files:
66
+ if not isinstance(entry, dict) or not isinstance(entry.get("path"), str):
67
+ raise SafetyError(f"ownership manifest at {manifest} has a malformed file entry")
68
+ sha256 = entry.get("sha256")
69
+ if not isinstance(sha256, str):
70
+ raise SafetyError(f"ownership manifest at {manifest} has a file entry without a digest")
71
+ owned.append(OwnedFile(path=entry["path"], sha256=sha256))
72
+ return Ownership(distribution_version=version, source=source, files=tuple(owned))
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class WriteStep:
77
+ path: str
78
+ content: bytes
79
+
80
+ def describe(self) -> str:
81
+ return f"write {self.path} ({len(self.content)} bytes)"
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class Plan:
86
+ """A previewable, confirmable set of changes to one host directory."""
87
+
88
+ root: Path
89
+ steps: tuple[WriteStep, ...]
90
+ removals: tuple[str, ...]
91
+ collisions: tuple[str, ...]
92
+ drifted: tuple[str, ...]
93
+ missing: tuple[str, ...]
94
+ notices: tuple[str, ...]
95
+ ownership_after: Ownership | None
96
+
97
+ @property
98
+ def empty(self) -> bool:
99
+ return not self.steps and not self.removals
100
+
101
+ def machine(self) -> dict[str, object]:
102
+ return {
103
+ "schema": "skillmate-plan/v1",
104
+ "root": str(self.root),
105
+ "steps": [step.describe() for step in self.steps],
106
+ "removals": list(self.removals),
107
+ "collisions": list(self.collisions),
108
+ "drifted": list(self.drifted),
109
+ "missing": list(self.missing),
110
+ "notices": list(self.notices),
111
+ }
112
+
113
+ def preview(self) -> str:
114
+ lines = [f"plan for {self.root}"]
115
+ lines.extend(f" {step.describe()}" for step in self.steps)
116
+ lines.extend(f" remove {path}" for path in self.removals)
117
+ lines.extend(f" collision (foreign file, untouched): {path}" for path in self.collisions)
118
+ lines.extend(f" drifted (user-modified, not overwritten): {path}" for path in self.drifted)
119
+ lines.extend(f" missing (recorded but absent): {path}" for path in self.missing)
120
+ lines.extend(f" notice: {notice}" for notice in self.notices)
121
+ return "\n".join(lines)
122
+
123
+
124
+ def _safe_relative(path: str) -> PurePosixPath:
125
+ relative = PurePosixPath(path)
126
+ if relative.is_absolute() or ".." in relative.parts or not str(relative):
127
+ raise SafetyError(f"refusing to plan a path outside the host directory: {path!r}")
128
+ return relative
129
+
130
+
131
+ def disk_digest(root: Path, path: str) -> str | None:
132
+ target = root / path
133
+ if not target.is_file():
134
+ return None
135
+ return digest(target.read_bytes())
136
+
137
+
138
+ def build_install_plan(
139
+ root: Path,
140
+ desired: dict[str, bytes],
141
+ ownership: Ownership | None,
142
+ distribution_version: str,
143
+ source: str,
144
+ notices: tuple[str, ...] = (),
145
+ ) -> Plan:
146
+ """Plan the transition of a host directory to ``desired``.
147
+
148
+ Targets that exist without ownership are collisions; owned files whose
149
+ bytes no longer match the record are drift and stay untouched. Clean
150
+ owned files and new paths are (re)written.
151
+ """
152
+ recorded = ownership.by_path() if ownership else {}
153
+ steps: list[WriteStep] = []
154
+ collisions: list[str] = []
155
+ drifted: list[str] = []
156
+ for path in sorted(desired):
157
+ _safe_relative(path)
158
+ disk = disk_digest(root, path)
159
+ if path not in recorded and disk is not None:
160
+ collisions.append(path)
161
+ continue
162
+ if path in recorded and disk is not None and disk != recorded[path]:
163
+ drifted.append(path)
164
+ continue
165
+ if path in recorded and digest(desired[path]) == recorded[path]:
166
+ continue
167
+ steps.append(WriteStep(path=path, content=desired[path]))
168
+ written = {
169
+ path: digest(content)
170
+ for path, content in desired.items()
171
+ if path not in set(drifted) and path not in set(collisions)
172
+ }
173
+ ownership_after = Ownership(
174
+ distribution_version=distribution_version,
175
+ source=source,
176
+ files=tuple(OwnedFile(path, sha) for path, sha in sorted(written.items())),
177
+ )
178
+ return Plan(
179
+ root=root,
180
+ steps=tuple(steps),
181
+ removals=(),
182
+ collisions=tuple(collisions),
183
+ drifted=tuple(drifted),
184
+ missing=(),
185
+ notices=notices,
186
+ ownership_after=ownership_after,
187
+ )
188
+
189
+
190
+ def build_remove_plan(
191
+ root: Path,
192
+ ownership: Ownership,
193
+ notices: tuple[str, ...] = (),
194
+ ) -> Plan:
195
+ """Plan the removal of exactly the files skillmate owns."""
196
+ removals: list[str] = []
197
+ drifted: list[str] = []
198
+ missing: list[str] = []
199
+ for owned in ownership.files:
200
+ _safe_relative(owned.path)
201
+ disk = disk_digest(root, owned.path)
202
+ if disk is None:
203
+ missing.append(owned.path)
204
+ elif disk != owned.sha256:
205
+ drifted.append(owned.path)
206
+ else:
207
+ removals.append(owned.path)
208
+ return Plan(
209
+ root=root,
210
+ steps=(),
211
+ removals=tuple(removals),
212
+ collisions=(),
213
+ drifted=tuple(drifted),
214
+ missing=tuple(missing),
215
+ notices=notices,
216
+ ownership_after=None,
217
+ )
218
+
219
+
220
+ @dataclass(frozen=True)
221
+ class ExecutionReport:
222
+ written: tuple[str, ...]
223
+ removed: tuple[str, ...]
224
+ manifest: str
225
+
226
+
227
+ def execute(plan: Plan, confirmed: bool, confirm_drift: bool = False) -> ExecutionReport:
228
+ """Apply a plan. Refuses to write anything unconfirmed or conflicted.
229
+
230
+ Drifted files are never written or removed unless ``confirm_drift`` is
231
+ set, matching the contracts that updates leave user drift in place and
232
+ removals of drifted owned files need explicit confirmation.
233
+ """
234
+ if not confirmed:
235
+ raise SafetyError("refusing to execute an unconfirmed plan; preview it first")
236
+ if plan.collisions:
237
+ raise SafetyError(
238
+ "foreign file collisions must be resolved first: " + ", ".join(plan.collisions)
239
+ )
240
+ removals = list(plan.removals)
241
+ if plan.ownership_after is None and confirm_drift:
242
+ removals.extend(plan.drifted)
243
+ keep_drifted = bool(plan.drifted) and plan.ownership_after is None and not confirm_drift
244
+
245
+ plan.root.mkdir(parents=True, exist_ok=True)
246
+ for step in plan.steps:
247
+ target = plan.root / step.path
248
+ target.parent.mkdir(parents=True, exist_ok=True)
249
+ temporary = target.with_name(target.name + ".skillmate-tmp")
250
+ temporary.write_bytes(step.content)
251
+ os.replace(temporary, target)
252
+ for path in removals:
253
+ target = plan.root / path
254
+ target.unlink(missing_ok=True)
255
+ _prune_empty_parents(plan.root, path)
256
+
257
+ manifest_path = ""
258
+ if plan.ownership_after is not None:
259
+ manifest = plan.root / MANIFEST_RELATIVE
260
+ manifest.parent.mkdir(parents=True, exist_ok=True)
261
+ document = {
262
+ "schema": MANIFEST_SCHEMA,
263
+ "version": plan.ownership_after.distribution_version,
264
+ "source": plan.ownership_after.source,
265
+ "files": [
266
+ {"path": owned.path, "sha256": owned.sha256} for owned in plan.ownership_after.files
267
+ ],
268
+ }
269
+ temporary = manifest.with_name("manifest.json.skillmate-tmp")
270
+ temporary.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
271
+ os.replace(temporary, manifest)
272
+ manifest_path = str(manifest)
273
+ elif keep_drifted:
274
+ # Drifted files remain on disk and stay owned; the manifest stays
275
+ # so a later confirmed removal can still find them.
276
+ manifest_path = str(plan.root / MANIFEST_RELATIVE)
277
+ else:
278
+ manifest = plan.root / MANIFEST_RELATIVE
279
+ if manifest.is_file():
280
+ manifest.unlink()
281
+ _prune_empty_parents(plan.root, MANIFEST_RELATIVE)
282
+ manifest_path = str(manifest)
283
+ return ExecutionReport(
284
+ written=tuple(step.path for step in plan.steps),
285
+ removed=tuple(removals),
286
+ manifest=manifest_path,
287
+ )
288
+
289
+
290
+ def _prune_empty_parents(root: Path, relative: str) -> None:
291
+ """Remove directories the removal emptied, never the host root itself."""
292
+ current = (root / relative).parent
293
+ while current != root and current.is_dir():
294
+ try:
295
+ current.rmdir()
296
+ except OSError:
297
+ return
298
+ current = current.parent