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.
- cursorfleet/__init__.py +9 -0
- cursorfleet/_templates/cursor/agents/architect.md +18 -0
- cursorfleet/_templates/cursor/agents/implementer.md +20 -0
- cursorfleet/_templates/cursor/agents/patcher.md +17 -0
- cursorfleet/_templates/cursor/agents/qa.md +21 -0
- cursorfleet/_templates/cursor/agents/reviewer.md +20 -0
- cursorfleet/_templates/cursor/agents/scout.md +17 -0
- cursorfleet/_templates/cursor/agents/tester.md +20 -0
- cursorfleet/_templates/cursor/agents-md/root.md +7 -0
- cursorfleet/_templates/cursor/agents-md/work.md +7 -0
- cursorfleet/_templates/cursor/config/config.toml +47 -0
- cursorfleet/_templates/cursor/hooks/entry.json +4 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-core.mdc +10 -0
- cursorfleet/_templates/cursor/rules/cursorfleet-handoff.mdc +15 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-artifacts/SKILL.md +57 -0
- cursorfleet/_templates/cursor/skills/cursorfleet-coordinator/SKILL.md +35 -0
- cursorfleet/adapters/__init__.py +0 -0
- cursorfleet/adapters/cursor/__init__.py +0 -0
- cursorfleet/adapters/cursor/blocks.py +104 -0
- cursorfleet/adapters/cursor/diagnostics.py +547 -0
- cursorfleet/adapters/cursor/fsutil.py +156 -0
- cursorfleet/adapters/cursor/hook_main.py +428 -0
- cursorfleet/adapters/cursor/hook_normalize.py +527 -0
- cursorfleet/adapters/cursor/hook_policy.py +76 -0
- cursorfleet/adapters/cursor/hook_sanitize.py +474 -0
- cursorfleet/adapters/cursor/hooksjson.py +305 -0
- cursorfleet/adapters/cursor/installer.py +626 -0
- cursorfleet/adapters/cursor/kit.py +210 -0
- cursorfleet/adapters/cursor/kit_probe.py +54 -0
- cursorfleet/adapters/cursor/lock.py +145 -0
- cursorfleet/adapters/cursor/templates.py +60 -0
- cursorfleet/adapters/cursor/validation.py +365 -0
- cursorfleet/adapters/cursor/workspace.py +80 -0
- cursorfleet/cli/__init__.py +0 -0
- cursorfleet/cli/commands/__init__.py +1 -0
- cursorfleet/cli/commands/_common.py +82 -0
- cursorfleet/cli/commands/_resolution.py +56 -0
- cursorfleet/cli/commands/doctor.py +81 -0
- cursorfleet/cli/commands/events.py +197 -0
- cursorfleet/cli/commands/index.py +55 -0
- cursorfleet/cli/commands/init.py +71 -0
- cursorfleet/cli/commands/migrate.py +43 -0
- cursorfleet/cli/commands/replay.py +76 -0
- cursorfleet/cli/commands/run.py +97 -0
- cursorfleet/cli/commands/status.py +210 -0
- cursorfleet/cli/commands/tui.py +47 -0
- cursorfleet/cli/commands/uninstall.py +73 -0
- cursorfleet/cli/commands/validate.py +69 -0
- cursorfleet/cli/main.py +81 -0
- cursorfleet/config/__init__.py +44 -0
- cursorfleet/config/io.py +27 -0
- cursorfleet/config/models.py +154 -0
- cursorfleet/config/roster.py +245 -0
- cursorfleet/events/__init__.py +39 -0
- cursorfleet/events/forbidden.py +45 -0
- cursorfleet/events/ids.py +109 -0
- cursorfleet/events/kinds.py +132 -0
- cursorfleet/events/models.py +213 -0
- cursorfleet/events/pathcheck.py +45 -0
- cursorfleet/git/__init__.py +0 -0
- cursorfleet/git/collector.py +226 -0
- cursorfleet/git/runner.py +135 -0
- cursorfleet/git/worktrees.py +71 -0
- cursorfleet/policy/__init__.py +0 -0
- cursorfleet/py.typed +0 -0
- cursorfleet/safeexe.py +70 -0
- cursorfleet/schema_gen.py +73 -0
- cursorfleet/state/__init__.py +0 -0
- cursorfleet/state/artifact_scan.py +309 -0
- cursorfleet/state/context.py +38 -0
- cursorfleet/state/event_store.py +227 -0
- cursorfleet/state/indexer.py +443 -0
- cursorfleet/state/lock.py +74 -0
- cursorfleet/state/models.py +268 -0
- cursorfleet/state/reducer.py +588 -0
- cursorfleet/state/retention.py +218 -0
- cursorfleet/state/run_builder.py +136 -0
- cursorfleet/state/run_model.py +43 -0
- cursorfleet/state/run_store.py +95 -0
- cursorfleet/state/runtime.py +645 -0
- cursorfleet/state/spool.py +186 -0
- cursorfleet/state/spool_read.py +242 -0
- cursorfleet/state/status_doc.py +293 -0
- cursorfleet/tui/__init__.py +0 -0
- cursorfleet/tui/app.py +581 -0
- cursorfleet/tui/data.py +545 -0
- cursorfleet/tui/filters.py +82 -0
- cursorfleet/tui/gates.py +255 -0
- cursorfleet/tui/help.py +101 -0
- cursorfleet/tui/layout.py +26 -0
- cursorfleet/tui/theme.py +176 -0
- cursorfleet/tui/views.py +1077 -0
- cursorfleet/tui/watch.py +33 -0
- cursorfleet/workflow/__init__.py +0 -0
- cursorfleet/workflow/artifacts.py +148 -0
- cursorfleet/workflow/frontmatter.py +232 -0
- cursorfleet-0.1.0.dist-info/METADATA +161 -0
- cursorfleet-0.1.0.dist-info/RECORD +101 -0
- cursorfleet-0.1.0.dist-info/WHEEL +4 -0
- cursorfleet-0.1.0.dist-info/entry_points.txt +3 -0
- 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")
|