cursorfleet 0.1.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.
Files changed (101) hide show
  1. cursorfleet/__init__.py +9 -0
  2. cursorfleet/_templates/cursor/agents/architect.md +18 -0
  3. cursorfleet/_templates/cursor/agents/implementer.md +20 -0
  4. cursorfleet/_templates/cursor/agents/patcher.md +17 -0
  5. cursorfleet/_templates/cursor/agents/qa.md +21 -0
  6. cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
  7. cursorfleet/_templates/cursor/agents/scout.md +17 -0
  8. cursorfleet/_templates/cursor/agents/tester.md +20 -0
  9. cursorfleet/_templates/cursor/agents-md/root.md +7 -0
  10. cursorfleet/_templates/cursor/agents-md/work.md +7 -0
  11. cursorfleet/_templates/cursor/config/config.toml +47 -0
  12. cursorfleet/_templates/cursor/hooks/entry.json +4 -0
  13. cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
  14. cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
  15. cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
  16. cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
  17. cursorfleet/adapters/__init__.py +0 -0
  18. cursorfleet/adapters/cursor/__init__.py +0 -0
  19. cursorfleet/adapters/cursor/blocks.py +104 -0
  20. cursorfleet/adapters/cursor/diagnostics.py +547 -0
  21. cursorfleet/adapters/cursor/fsutil.py +156 -0
  22. cursorfleet/adapters/cursor/hook_main.py +428 -0
  23. cursorfleet/adapters/cursor/hook_normalize.py +527 -0
  24. cursorfleet/adapters/cursor/hook_policy.py +76 -0
  25. cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
  26. cursorfleet/adapters/cursor/hooksjson.py +305 -0
  27. cursorfleet/adapters/cursor/installer.py +626 -0
  28. cursorfleet/adapters/cursor/kit.py +210 -0
  29. cursorfleet/adapters/cursor/kit_probe.py +54 -0
  30. cursorfleet/adapters/cursor/lock.py +145 -0
  31. cursorfleet/adapters/cursor/templates.py +60 -0
  32. cursorfleet/adapters/cursor/validation.py +365 -0
  33. cursorfleet/adapters/cursor/workspace.py +80 -0
  34. cursorfleet/cli/__init__.py +0 -0
  35. cursorfleet/cli/commands/__init__.py +1 -0
  36. cursorfleet/cli/commands/_common.py +82 -0
  37. cursorfleet/cli/commands/_resolution.py +56 -0
  38. cursorfleet/cli/commands/doctor.py +81 -0
  39. cursorfleet/cli/commands/events.py +197 -0
  40. cursorfleet/cli/commands/index.py +55 -0
  41. cursorfleet/cli/commands/init.py +71 -0
  42. cursorfleet/cli/commands/migrate.py +43 -0
  43. cursorfleet/cli/commands/replay.py +76 -0
  44. cursorfleet/cli/commands/run.py +97 -0
  45. cursorfleet/cli/commands/status.py +210 -0
  46. cursorfleet/cli/commands/tui.py +47 -0
  47. cursorfleet/cli/commands/uninstall.py +73 -0
  48. cursorfleet/cli/commands/validate.py +69 -0
  49. cursorfleet/cli/main.py +81 -0
  50. cursorfleet/config/__init__.py +44 -0
  51. cursorfleet/config/io.py +27 -0
  52. cursorfleet/config/models.py +154 -0
  53. cursorfleet/config/roster.py +245 -0
  54. cursorfleet/events/__init__.py +39 -0
  55. cursorfleet/events/forbidden.py +45 -0
  56. cursorfleet/events/ids.py +109 -0
  57. cursorfleet/events/kinds.py +132 -0
  58. cursorfleet/events/models.py +213 -0
  59. cursorfleet/events/pathcheck.py +45 -0
  60. cursorfleet/git/__init__.py +0 -0
  61. cursorfleet/git/collector.py +226 -0
  62. cursorfleet/git/runner.py +135 -0
  63. cursorfleet/git/worktrees.py +71 -0
  64. cursorfleet/policy/__init__.py +0 -0
  65. cursorfleet/py.typed +0 -0
  66. cursorfleet/safeexe.py +70 -0
  67. cursorfleet/schema_gen.py +73 -0
  68. cursorfleet/state/__init__.py +0 -0
  69. cursorfleet/state/artifact_scan.py +309 -0
  70. cursorfleet/state/context.py +38 -0
  71. cursorfleet/state/event_store.py +227 -0
  72. cursorfleet/state/indexer.py +443 -0
  73. cursorfleet/state/lock.py +74 -0
  74. cursorfleet/state/models.py +268 -0
  75. cursorfleet/state/reducer.py +588 -0
  76. cursorfleet/state/retention.py +218 -0
  77. cursorfleet/state/run_builder.py +136 -0
  78. cursorfleet/state/run_model.py +43 -0
  79. cursorfleet/state/run_store.py +95 -0
  80. cursorfleet/state/runtime.py +645 -0
  81. cursorfleet/state/spool.py +186 -0
  82. cursorfleet/state/spool_read.py +242 -0
  83. cursorfleet/state/status_doc.py +293 -0
  84. cursorfleet/tui/__init__.py +0 -0
  85. cursorfleet/tui/app.py +581 -0
  86. cursorfleet/tui/data.py +545 -0
  87. cursorfleet/tui/filters.py +82 -0
  88. cursorfleet/tui/gates.py +255 -0
  89. cursorfleet/tui/help.py +101 -0
  90. cursorfleet/tui/layout.py +26 -0
  91. cursorfleet/tui/theme.py +176 -0
  92. cursorfleet/tui/views.py +1077 -0
  93. cursorfleet/tui/watch.py +33 -0
  94. cursorfleet/workflow/__init__.py +0 -0
  95. cursorfleet/workflow/artifacts.py +148 -0
  96. cursorfleet/workflow/frontmatter.py +232 -0
  97. cursorfleet-0.1.0.dist-info/METADATA +161 -0
  98. cursorfleet-0.1.0.dist-info/RECORD +101 -0
  99. cursorfleet-0.1.0.dist-info/WHEEL +4 -0
  100. cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
  101. cursorfleet-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,626 @@
1
+ """Plan, preview, apply and undo the Cursor kit install.
2
+
3
+ ``build_install_plan`` and ``build_uninstall_plan`` are pure with respect to the
4
+ workspace: they only read files and return a :class:`Plan`. ``render_diff``
5
+ prints exactly what ``apply_plan`` will write. Nothing is written without a plan,
6
+ and the lockfile is always written last so a crash never records files that
7
+ were not written.
8
+
9
+ Invariants (tested):
10
+
11
+ * only paths on the managed allowlist are touched, never through symlinks;
12
+ * user content in ``hooks.json`` and ``AGENTS.md`` is preserved byte-for-byte
13
+ where possible, and ``uninstall`` restores the pre-install bytes;
14
+ * ``init`` refuses on dirty conflicts instead of overwriting;
15
+ * ``init`` twice is a no-op (the lock has no timestamps);
16
+ * hooks emitted are exactly the enabled subset of ``ALLOWED_V01_HOOKS``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import binascii
23
+ import difflib
24
+ from dataclasses import dataclass, field
25
+ from enum import StrEnum
26
+ from pathlib import Path
27
+
28
+ from pydantic import ValidationError
29
+
30
+ from cursorfleet import __version__
31
+ from cursorfleet.adapters.cursor import blocks, hooksjson
32
+ from cursorfleet.adapters.cursor.fsutil import (
33
+ UnsafePathError,
34
+ atomic_write_text,
35
+ ensure_inside,
36
+ read_text,
37
+ remove_empty_dirs,
38
+ safe_text,
39
+ sha256_text,
40
+ )
41
+ from cursorfleet.adapters.cursor.kit import (
42
+ CONFIG_PATH,
43
+ HOOKS_PATH,
44
+ LOCK_PATH,
45
+ ROSTER_PATH,
46
+ build_kit,
47
+ seed_config_text,
48
+ seed_roster_text,
49
+ )
50
+ from cursorfleet.adapters.cursor.lock import (
51
+ InstallLock,
52
+ LockBlock,
53
+ LockFile,
54
+ LockHooks,
55
+ dumps_lock,
56
+ loads_lock,
57
+ )
58
+ from cursorfleet.config.io import loads_config, loads_roster
59
+ from cursorfleet.config.models import FleetConfig
60
+ from cursorfleet.config.roster import Roster, default_roster
61
+
62
+
63
+ class Action(StrEnum):
64
+ CREATE = "create"
65
+ MODIFY = "modify"
66
+ DELETE = "delete"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Change:
71
+ path: str
72
+ action: Action
73
+ old: str | None
74
+ new: str | None
75
+
76
+
77
+ @dataclass
78
+ class Plan:
79
+ root: Path
80
+ changes: list[Change] = field(default_factory=list)
81
+ conflicts: list[str] = field(default_factory=list)
82
+ drift: list[str] = field(default_factory=list)
83
+ notes: list[str] = field(default_factory=list)
84
+ remove_dirs: list[str] = field(default_factory=list)
85
+
86
+ @property
87
+ def noop(self) -> bool:
88
+ return not self.changes and not self.remove_dirs
89
+
90
+ @property
91
+ def touches_hooks(self) -> bool:
92
+ return any(c.path == HOOKS_PATH for c in self.changes)
93
+
94
+
95
+ class _Reader:
96
+ """Reads workspace files safely and turns problems into conflict messages."""
97
+
98
+ def __init__(self, root: Path, problems: list[str]) -> None:
99
+ self.root = root
100
+ self.problems = problems
101
+
102
+ def read(self, rel: str) -> str | None:
103
+ try:
104
+ return read_text(ensure_inside(self.root, rel))
105
+ except (UnsafePathError, ValueError) as exc:
106
+ self.problems.append(f"{rel}: {exc}")
107
+ return None
108
+
109
+
110
+ # --------------------------------------------------------------------------------------
111
+ # Inputs
112
+
113
+
114
+ def load_inputs(root: Path, problems: list[str]) -> tuple[FleetConfig, Roster]:
115
+ """Load committed config/roster if present, else defaults. Problems become conflicts."""
116
+ reader = _Reader(root, problems)
117
+ config = FleetConfig()
118
+ roster = default_roster()
119
+ cfg_text = reader.read(CONFIG_PATH)
120
+ if cfg_text is not None:
121
+ try:
122
+ config = loads_config(cfg_text)
123
+ except (ValueError, ValidationError) as exc:
124
+ problems.append(f"{CONFIG_PATH}: invalid ({_first_line(exc)})")
125
+ roster_text = reader.read(ROSTER_PATH)
126
+ if roster_text is not None:
127
+ try:
128
+ roster = loads_roster(roster_text)
129
+ except (ValueError, ValidationError) as exc:
130
+ problems.append(f"{ROSTER_PATH}: invalid ({_first_line(exc)})")
131
+ return config, roster
132
+
133
+
134
+ def _first_line(exc: Exception) -> str:
135
+ return (str(exc).strip().splitlines() or ["error"])[0][:200]
136
+
137
+
138
+ def read_lock(root: Path, problems: list[str]) -> InstallLock | None:
139
+ text = _Reader(root, problems).read(LOCK_PATH)
140
+ if text is None:
141
+ return None
142
+ try:
143
+ return loads_lock(text)
144
+ except (ValueError, ValidationError) as exc:
145
+ problems.append(f"{LOCK_PATH}: invalid lockfile ({_first_line(exc)})")
146
+ return None
147
+
148
+
149
+ def _missing_dirs(root: Path, rel_path: str) -> list[str]:
150
+ parts = rel_path.split("/")[:-1]
151
+ missing: list[str] = []
152
+ for i in range(1, len(parts) + 1):
153
+ rel = "/".join(parts[:i])
154
+ if not root.joinpath(*parts[:i]).exists():
155
+ missing.append(rel)
156
+ return missing
157
+
158
+
159
+ # --------------------------------------------------------------------------------------
160
+ # Install
161
+
162
+
163
+ def build_install_plan(root: Path) -> Plan: # noqa: PLR0912, PLR0915
164
+ """Compute what ``init --cursor`` would do in ``root`` (read-only)."""
165
+ plan = Plan(root=root)
166
+ reader = _Reader(root, plan.conflicts)
167
+ config, roster = load_inputs(root, plan.conflicts)
168
+ old_lock = read_lock(root, plan.conflicts)
169
+ if plan.conflicts:
170
+ return plan
171
+
172
+ kit = build_kit(roster, config)
173
+ old_files = old_lock.file_map() if old_lock else {}
174
+ old_blocks = old_lock.block_map() if old_lock else {}
175
+ new_files: list[LockFile] = []
176
+ new_blocks: list[LockBlock] = []
177
+ changes: list[Change] = []
178
+
179
+ # 1. Whole generated files.
180
+ for rel, desired in kit.files.items():
181
+ disk = reader.read(rel)
182
+ entry = old_files.get(rel)
183
+ digest = sha256_text(desired)
184
+ if disk is None:
185
+ changes.append(Change(rel, Action.CREATE, None, desired))
186
+ new_files.append(LockFile(path=rel, sha256=digest))
187
+ elif sha256_text(disk) == digest:
188
+ new_files.append(
189
+ LockFile(path=rel, sha256=digest, adopted=entry.adopted if entry else True)
190
+ )
191
+ elif entry is not None and sha256_text(disk) == entry.sha256:
192
+ changes.append(Change(rel, Action.MODIFY, disk, desired))
193
+ new_files.append(LockFile(path=rel, sha256=digest, adopted=entry.adopted))
194
+ elif entry is not None:
195
+ plan.conflicts.append(
196
+ f"{rel}: modified since CursorFleet installed it; move your changes into "
197
+ f"{ROSTER_PATH} or delete the file, then re-run init"
198
+ )
199
+ else:
200
+ plan.conflicts.append(
201
+ f"{rel}: already exists and is not managed by CursorFleet; "
202
+ "rename or remove it, then re-run init"
203
+ )
204
+
205
+ # 2. Files from an earlier install that the current roster no longer generates.
206
+ for rel, entry in old_files.items():
207
+ if rel in kit.files:
208
+ continue
209
+ if entry.kind == "seed":
210
+ continue # carried over in step 3
211
+ disk = reader.read(rel)
212
+ if disk is None:
213
+ continue
214
+ if sha256_text(disk) == entry.sha256 and not entry.adopted:
215
+ changes.append(Change(rel, Action.DELETE, disk, None))
216
+ else:
217
+ plan.notes.append(f"{rel}: no longer generated; left in place (modified or adopted)")
218
+
219
+ # 3. Seed files (config, roster): written only if absent, user-owned afterwards.
220
+ seeds = {CONFIG_PATH: seed_config_text(), ROSTER_PATH: seed_roster_text(roster)}
221
+ for rel, text in seeds.items():
222
+ disk = reader.read(rel)
223
+ if disk is None:
224
+ changes.append(Change(rel, Action.CREATE, None, text))
225
+ new_files.append(LockFile(path=rel, sha256=sha256_text(text), kind="seed"))
226
+ elif rel in old_files:
227
+ new_files.append(old_files[rel])
228
+
229
+ # 4. Managed blocks in AGENTS.md files.
230
+ for rel, block in kit.blocks.items():
231
+ _plan_block(plan, reader, rel, block, old_blocks.get(rel), new_blocks, changes)
232
+
233
+ # 5. hooks.json.
234
+ lock_hooks = _plan_hooks(plan, reader, config, old_lock, changes)
235
+
236
+ if plan.conflicts:
237
+ return plan
238
+
239
+ # 6. Directories we will create, and the lockfile itself (always last).
240
+ dirs = list(old_lock.dirs_created) if old_lock else []
241
+ for change in [*changes, Change(LOCK_PATH, Action.CREATE, None, "")]:
242
+ if change.action is Action.CREATE:
243
+ for d in _missing_dirs(root, change.path):
244
+ if d not in dirs:
245
+ dirs.append(d)
246
+ new_lock = InstallLock(
247
+ cursorfleet_version=__version__,
248
+ files=sorted(new_files, key=lambda f: f.path),
249
+ blocks=sorted(new_blocks, key=lambda b: b.path),
250
+ hooks=lock_hooks,
251
+ dirs_created=dirs,
252
+ )
253
+ new_text = dumps_lock(new_lock)
254
+ old_text = reader.read(LOCK_PATH)
255
+ if old_text != new_text and not plan.conflicts:
256
+ action = Action.CREATE if old_text is None else Action.MODIFY
257
+ changes.append(Change(LOCK_PATH, action, old_text, new_text))
258
+ plan.changes = changes
259
+ return plan
260
+
261
+
262
+ def _plan_block( # noqa: PLR0913, PLR0917
263
+ plan: Plan,
264
+ reader: _Reader,
265
+ rel: str,
266
+ block: str,
267
+ entry: LockBlock | None,
268
+ new_blocks: list[LockBlock],
269
+ changes: list[Change],
270
+ ) -> None:
271
+ digest = sha256_text(block)
272
+ disk = reader.read(rel)
273
+ if disk is None:
274
+ text, _ = blocks.insert_block("", block)
275
+ changes.append(Change(rel, Action.CREATE, None, text))
276
+ new_blocks.append(LockBlock(path=rel, sha256=digest, prefix="", file_created=True))
277
+ return
278
+ try:
279
+ current = blocks.block_text(disk)
280
+ except blocks.BlockError as exc:
281
+ plan.conflicts.append(f"{rel}: {exc}; fix the markers by hand, then re-run init")
282
+ return
283
+ if current is None:
284
+ text, prefix = blocks.insert_block(disk, block)
285
+ changes.append(Change(rel, Action.MODIFY, disk, text))
286
+ new_blocks.append(LockBlock(path=rel, sha256=digest, prefix=prefix))
287
+ elif sha256_text(current) == digest:
288
+ new_blocks.append(
289
+ entry.model_copy(update={"sha256": digest})
290
+ if entry
291
+ else LockBlock(path=rel, sha256=digest)
292
+ )
293
+ elif entry is not None and sha256_text(current) == entry.sha256:
294
+ changes.append(Change(rel, Action.MODIFY, disk, blocks.replace_block(disk, block)))
295
+ new_blocks.append(entry.model_copy(update={"sha256": digest}))
296
+ else:
297
+ plan.conflicts.append(
298
+ f"{rel}: the CursorFleet managed block was edited by hand; "
299
+ "restore it or remove the block, then re-run init"
300
+ )
301
+
302
+
303
+ def _plan_hooks(
304
+ plan: Plan,
305
+ reader: _Reader,
306
+ config: FleetConfig,
307
+ old_lock: InstallLock | None,
308
+ changes: list[Change],
309
+ ) -> LockHooks | None:
310
+ try:
311
+ wanted = hooksjson.assert_registrable(config.hooks.enabled)
312
+ except hooksjson.HooksJsonError as exc: # unreachable via config; defence in depth
313
+ plan.conflicts.append(str(exc))
314
+ return None
315
+ disk = reader.read(HOOKS_PATH)
316
+ old = old_lock.hooks if old_lock else None
317
+ if disk is None and not wanted:
318
+ return None
319
+ previous_state = (
320
+ hooksjson.HooksState(
321
+ added_version=old.added_version,
322
+ added_hooks_key=old.added_hooks_key,
323
+ added_events=list(old.added_events),
324
+ entries=dict(old.entries),
325
+ )
326
+ if old
327
+ else None
328
+ )
329
+ try:
330
+ merged = hooksjson.merge(
331
+ disk, wanted, previous=old.entries if old else None, previous_state=previous_state
332
+ )
333
+ except hooksjson.HooksJsonError as exc:
334
+ plan.conflicts.append(
335
+ f"{HOOKS_PATH}: {exc}; fix the file by hand (CursorFleet never rewrites invalid JSON)"
336
+ )
337
+ return None
338
+ plan.conflicts.extend(f"{HOOKS_PATH}: {c}" for c in merged.conflicts)
339
+
340
+ if disk is None:
341
+ changes.append(Change(HOOKS_PATH, Action.CREATE, None, merged.text))
342
+ elif merged.text != disk:
343
+ changes.append(Change(HOOKS_PATH, Action.MODIFY, disk, merged.text))
344
+
345
+ if old is not None:
346
+ original_sha, original_b64 = old.original_sha256, old.original_b64
347
+ created = old.created
348
+ else:
349
+ created = disk is None
350
+ original_sha = sha256_text(disk) if disk is not None else None
351
+ original_b64 = (
352
+ base64.b64encode(disk.encode("utf-8")).decode("ascii")
353
+ if disk is not None and not merged.reproducible
354
+ else None
355
+ )
356
+ return LockHooks(
357
+ created=created,
358
+ added_version=merged.state.added_version,
359
+ added_hooks_key=merged.state.added_hooks_key,
360
+ added_events=merged.state.added_events,
361
+ entries=merged.state.entries,
362
+ style=merged.style,
363
+ installed_sha256=sha256_text(merged.text),
364
+ original_sha256=original_sha,
365
+ original_b64=original_b64,
366
+ )
367
+
368
+
369
+ # --------------------------------------------------------------------------------------
370
+ # Uninstall
371
+
372
+
373
+ def build_uninstall_plan(root: Path, *, force: bool = False) -> Plan: # noqa: PLR0912
374
+ """Compute what ``uninstall`` would remove, driven only by the lockfile."""
375
+ plan = Plan(root=root)
376
+ reader = _Reader(root, plan.conflicts)
377
+ lock = read_lock(root, plan.conflicts)
378
+ if lock is None:
379
+ if not plan.conflicts:
380
+ plan.notes.append("No CursorFleet install found (no .cursorfleet/install.lock.json).")
381
+ return plan
382
+
383
+ changes: list[Change] = []
384
+ keep_files: list[LockFile] = []
385
+ keep_blocks: list[LockBlock] = []
386
+ keep_hooks: LockHooks | None = None
387
+
388
+ if lock.hooks is not None:
389
+ keep_hooks = _plan_unhooks(plan, reader, lock.hooks, force, changes)
390
+
391
+ for entry in lock.blocks:
392
+ kept = _plan_unblock(plan, reader, entry, force, changes)
393
+ if kept is not None:
394
+ keep_blocks.append(kept)
395
+
396
+ for fentry in lock.files:
397
+ disk = reader.read(fentry.path)
398
+ if disk is None:
399
+ continue
400
+ matches = sha256_text(disk) == fentry.sha256
401
+ if fentry.kind == "seed":
402
+ if matches:
403
+ changes.append(Change(fentry.path, Action.DELETE, disk, None))
404
+ else:
405
+ plan.notes.append(f"{fentry.path}: edited by you; kept (yours now)")
406
+ elif fentry.adopted:
407
+ plan.notes.append(f"{fentry.path}: existed before init with identical content; kept")
408
+ elif matches or force:
409
+ changes.append(Change(fentry.path, Action.DELETE, disk, None))
410
+ else:
411
+ plan.drift.append(f"{fentry.path}: modified since install; skipped (use --force)")
412
+ keep_files.append(fentry)
413
+
414
+ lock_text = reader.read(LOCK_PATH)
415
+ if keep_files or keep_blocks or keep_hooks is not None:
416
+ remaining = lock.model_copy(
417
+ update={"files": keep_files, "blocks": keep_blocks, "hooks": keep_hooks}
418
+ )
419
+ new_text = dumps_lock(remaining)
420
+ if new_text != lock_text:
421
+ changes.append(Change(LOCK_PATH, Action.MODIFY, lock_text, new_text))
422
+ else:
423
+ changes.append(Change(LOCK_PATH, Action.DELETE, lock_text, None))
424
+ plan.remove_dirs = list(lock.dirs_created)
425
+ plan.changes = changes
426
+ return plan
427
+
428
+
429
+ def _plan_unhooks(
430
+ plan: Plan, reader: _Reader, entry: LockHooks, force: bool, changes: list[Change]
431
+ ) -> LockHooks | None:
432
+ """Return the lock entry to keep (``None`` when hooks are fully handled)."""
433
+ disk = reader.read(HOOKS_PATH)
434
+ if disk is None:
435
+ return None
436
+ state = hooksjson.HooksState(
437
+ added_version=entry.added_version,
438
+ added_hooks_key=entry.added_hooks_key,
439
+ added_events=list(entry.added_events),
440
+ entries=dict(entry.entries),
441
+ )
442
+ try:
443
+ result = hooksjson.unmerge(disk, state, entry.style, created=entry.created, force=force)
444
+ except hooksjson.HooksJsonError as exc:
445
+ plan.drift.append(f"{HOOKS_PATH}: {exc}; skipped")
446
+ return entry
447
+ if result.drift:
448
+ plan.drift.extend(f"{HOOKS_PATH}: {d}; skipped (use --force)" for d in result.drift)
449
+ return entry
450
+ new_text = result.text
451
+ unchanged_since_install = sha256_text(disk) == entry.installed_sha256
452
+ if (
453
+ entry.original_b64
454
+ and entry.original_sha256
455
+ and unchanged_since_install
456
+ and sha256_text(new_text) != entry.original_sha256
457
+ ):
458
+ try:
459
+ restored = base64.b64decode(entry.original_b64, validate=True).decode("utf-8")
460
+ except (binascii.Error, UnicodeDecodeError):
461
+ restored = None
462
+ if restored is not None and sha256_text(restored) == entry.original_sha256:
463
+ new_text = restored
464
+ if result.now_empty:
465
+ changes.append(Change(HOOKS_PATH, Action.DELETE, disk, None))
466
+ elif new_text != disk:
467
+ changes.append(Change(HOOKS_PATH, Action.MODIFY, disk, new_text))
468
+ return None
469
+
470
+
471
+ def _plan_unblock(
472
+ plan: Plan, reader: _Reader, entry: LockBlock, force: bool, changes: list[Change]
473
+ ) -> LockBlock | None:
474
+ disk = reader.read(entry.path)
475
+ if disk is None:
476
+ return None
477
+ try:
478
+ current = blocks.block_text(disk)
479
+ except blocks.BlockError as exc:
480
+ plan.drift.append(f"{entry.path}: {exc}; skipped")
481
+ return entry
482
+ if current is None:
483
+ return None
484
+ if sha256_text(current) != entry.sha256 and not force:
485
+ plan.drift.append(f"{entry.path}: managed block was edited; skipped (use --force)")
486
+ return entry
487
+ new_text = blocks.remove_block(disk, entry.prefix)
488
+ if entry.file_created and new_text.strip() == "":
489
+ changes.append(Change(entry.path, Action.DELETE, disk, None))
490
+ else:
491
+ changes.append(Change(entry.path, Action.MODIFY, disk, new_text))
492
+ return None
493
+
494
+
495
+ # --------------------------------------------------------------------------------------
496
+ # Presentation and apply
497
+
498
+
499
+ def _diff_lines(text: str | None) -> list[str]:
500
+ if not text:
501
+ return []
502
+ lines = text.splitlines(keepends=True)
503
+ if lines and not lines[-1].endswith("\n"):
504
+ lines[-1] += "\n\\n"
505
+ return lines
506
+
507
+
508
+ def render_diff(plan: Plan) -> str:
509
+ """Exact unified diff of every change, safe to print to a terminal."""
510
+ out: list[str] = []
511
+ for change in plan.changes:
512
+ old = _diff_lines(change.old)
513
+ new = _diff_lines(change.new)
514
+ from_file = "/dev/null" if change.action is Action.CREATE else f"a/{change.path}"
515
+ to_file = "/dev/null" if change.action is Action.DELETE else f"b/{change.path}"
516
+ out.append(f"diff --cursorfleet {change.action.value} {change.path}\n")
517
+ out.extend(difflib.unified_diff(old, new, fromfile=from_file, tofile=to_file))
518
+ return safe_text("".join(out))
519
+
520
+
521
+ def summarize(plan: Plan) -> list[str]:
522
+ return [f"{c.action.value:<7} {safe_text(c.path)}" for c in plan.changes] + [
523
+ f"rmdir {safe_text(d)} (if empty)" for d in plan.remove_dirs
524
+ ]
525
+
526
+
527
+ def apply_plan(plan: Plan) -> None:
528
+ """Write ``plan`` to disk atomically, file by file, lockfile last (or removed last)."""
529
+ if plan.conflicts:
530
+ msg = "refusing to apply a plan with conflicts"
531
+ raise RuntimeError(msg)
532
+ for change in plan.changes:
533
+ target = ensure_inside(plan.root, change.path)
534
+ if change.action is Action.DELETE:
535
+ target.unlink(missing_ok=True)
536
+ elif change.new is not None:
537
+ atomic_write_text(target, change.new)
538
+ if plan.remove_dirs:
539
+ remove_empty_dirs(plan.root, plan.remove_dirs)
540
+
541
+
542
+ # --------------------------------------------------------------------------------------
543
+ # Drift (used by doctor and validate)
544
+
545
+
546
+ @dataclass(frozen=True)
547
+ class DriftItem:
548
+ path: str
549
+ kind: str # file | seed | block | hooks
550
+ status: str # ok | modified | missing | edited
551
+ detail: str = ""
552
+
553
+ @property
554
+ def is_problem(self) -> bool:
555
+ return self.status in {"modified", "missing"}
556
+
557
+
558
+ def check_drift(root: Path, lock: InstallLock) -> list[DriftItem]: # noqa: PLR0912
559
+ """Compare the workspace with the lockfile. Read-only; never raises on bad files."""
560
+ items: list[DriftItem] = []
561
+ problems: list[str] = []
562
+ reader = _Reader(root, problems)
563
+ for f in lock.files:
564
+ disk = reader.read(f.path)
565
+ if disk is None:
566
+ if f.kind == "seed":
567
+ items.append(DriftItem(f.path, "seed", "missing", "removed after install"))
568
+ else:
569
+ items.append(DriftItem(f.path, "file", "missing", "installed file is gone"))
570
+ elif sha256_text(disk) == f.sha256:
571
+ items.append(DriftItem(f.path, f.kind, "ok"))
572
+ elif f.kind == "seed":
573
+ items.append(DriftItem(f.path, "seed", "edited", "user-edited; expected"))
574
+ else:
575
+ items.append(DriftItem(f.path, "file", "modified", "content differs from install"))
576
+ for b in lock.blocks:
577
+ disk = reader.read(b.path)
578
+ try:
579
+ current = blocks.block_text(disk) if disk is not None else None
580
+ except blocks.BlockError as exc:
581
+ items.append(DriftItem(b.path, "block", "modified", str(exc)))
582
+ continue
583
+ if current is None:
584
+ items.append(DriftItem(b.path, "block", "missing", "managed block is gone"))
585
+ elif sha256_text(current) == b.sha256:
586
+ items.append(DriftItem(b.path, "block", "ok"))
587
+ else:
588
+ items.append(DriftItem(b.path, "block", "modified", "managed block was edited"))
589
+ if lock.hooks is not None:
590
+ items.append(_check_hooks(reader, lock.hooks))
591
+ return items
592
+
593
+
594
+ def _check_hooks(reader: _Reader, entry: LockHooks) -> DriftItem:
595
+ disk = reader.read(HOOKS_PATH)
596
+ if disk is None:
597
+ return DriftItem(HOOKS_PATH, "hooks", "missing", "hooks.json is gone")
598
+ try:
599
+ events = hooksjson.effective_events(disk)
600
+ except hooksjson.HooksJsonError as exc:
601
+ return DriftItem(HOOKS_PATH, "hooks", "modified", str(exc))
602
+ missing: list[str] = []
603
+ modified: list[str] = []
604
+ for event, recorded in entry.entries.items():
605
+ ours = [e for e in events.get(event, []) if hooksjson.is_ours(e)]
606
+ if not ours:
607
+ missing.append(event)
608
+ elif ours != [recorded]:
609
+ modified.append(event)
610
+ extra = sorted(
611
+ e
612
+ for e, entries in events.items()
613
+ if e not in entry.entries and any(hooksjson.is_ours(x) for x in entries)
614
+ )
615
+ if missing or modified or extra:
616
+ detail = "; ".join(
617
+ part
618
+ for part in (
619
+ f"missing: {', '.join(missing)}" if missing else "",
620
+ f"modified: {', '.join(modified)}" if modified else "",
621
+ f"unrecorded: {', '.join(extra)}" if extra else "",
622
+ )
623
+ if part
624
+ )
625
+ return DriftItem(HOOKS_PATH, "hooks", "modified", detail)
626
+ return DriftItem(HOOKS_PATH, "hooks", "ok")