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,547 @@
1
+ """``cursorfleet doctor`` checks. Read-only, tolerant of absent or unreadable files.
2
+
3
+ Every subprocess uses an argv list and a timeout. Nothing here writes to disk.
4
+ PROVISIONAL: the hooks.json locations for enterprise, user and project levels
5
+ come from the Cursor docs (ADR 0001 A4); Cursor's behavior with them is unverified.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import shutil
13
+ import stat
14
+ import subprocess
15
+ import sys
16
+ from collections.abc import Mapping
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
19
+ from typing import Any, Literal
20
+
21
+ from cursorfleet.adapters.cursor import hooksjson
22
+ from cursorfleet.adapters.cursor.fsutil import UnsafePathError, safe_text
23
+ from cursorfleet.adapters.cursor.hook_policy import FORBIDDEN_HOOKS
24
+ from cursorfleet.adapters.cursor.installer import check_drift, load_inputs, read_lock
25
+ from cursorfleet.adapters.cursor.kit import HOOKS_PATH, LOCK_PATH
26
+ from cursorfleet.adapters.cursor.workspace import Workspace
27
+ from cursorfleet.safeexe import find_executable
28
+ from cursorfleet.state.runtime import Resolution, inspect_repository, untrusted_reason
29
+
30
+ Status = Literal["ok", "warn", "fail", "info"]
31
+ HOOK_BINARY = "cursorfleet-hook"
32
+ MIN_PYTHON = (3, 11)
33
+ CURSOR_PROBE_TIMEOUT_S = 5.0
34
+ MAX_PERMISSION_SCAN = 2000
35
+ _VERSION_RE = re.compile(r"^[0-9]+(\.[0-9]+){1,3}([-+.][A-Za-z0-9.]{1,32})?$")
36
+ _COMMAND_DISPLAY_CHARS = 100
37
+
38
+
39
+ @dataclass
40
+ class Check:
41
+ id: str
42
+ status: Status
43
+ message: str
44
+ remediation: str | None = None
45
+ details: dict[str, Any] = field(default_factory=dict)
46
+
47
+ def as_dict(self) -> dict[str, Any]:
48
+ return {
49
+ "id": self.id,
50
+ "status": self.status,
51
+ "message": self.message,
52
+ "remediation": self.remediation,
53
+ "details": self.details,
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class HooksSource:
59
+ level: str
60
+ path: str | None
61
+ exists: bool
62
+ readable: bool
63
+ error: str | None = None
64
+ note: str | None = None
65
+ events: dict[str, list[dict[str, Any]]] = field(default_factory=dict)
66
+
67
+ def as_dict(self) -> dict[str, Any]:
68
+ return {
69
+ "level": self.level,
70
+ "path": self.path,
71
+ "exists": self.exists,
72
+ "readable": self.readable,
73
+ "error": self.error,
74
+ "note": self.note,
75
+ "events": self.events,
76
+ }
77
+
78
+
79
+ @dataclass
80
+ class DoctorReport:
81
+ checks: list[Check]
82
+ hooks: list[HooksSource]
83
+ cursor_version: str | None
84
+ runtime_dir: str | None
85
+ resolution: Resolution | None = None
86
+
87
+ @property
88
+ def ok(self) -> bool:
89
+ return not any(c.status == "fail" for c in self.checks)
90
+
91
+ def counts(self) -> dict[str, int]:
92
+ result = {"ok": 0, "warn": 0, "fail": 0, "info": 0}
93
+ for check in self.checks:
94
+ result[check.status] += 1
95
+ return result
96
+
97
+ def as_dict(self) -> dict[str, Any]:
98
+ return {
99
+ "schema": "cursorfleet.doctor/1",
100
+ "ok": self.ok,
101
+ "summary": self.counts(),
102
+ "cursor_version": self.cursor_version,
103
+ "runtime_dir": self.runtime_dir,
104
+ "resolution": None if self.resolution is None else self.resolution.as_dict(),
105
+ "checks": [c.as_dict() for c in self.checks],
106
+ "effective_hooks": [h.as_dict() for h in self.hooks],
107
+ }
108
+
109
+
110
+ def enterprise_hooks_path(platform: str, env: Mapping[str, str]) -> PurePath:
111
+ """Documented enterprise (MDM) hooks.json location for ``platform`` (PROVISIONAL)."""
112
+ if platform.startswith("win"):
113
+ base = env.get("PROGRAMDATA") or "C:\\ProgramData"
114
+ return PureWindowsPath(base) / "Cursor" / "hooks.json"
115
+ if platform == "darwin":
116
+ return PurePosixPath("/Library/Application Support/Cursor/hooks.json")
117
+ return PurePosixPath("/etc/cursor/hooks.json")
118
+
119
+
120
+ def _summarize_entry(entry: dict[str, Any]) -> dict[str, Any]:
121
+ command = entry.get("command")
122
+ shown = safe_text(command[:_COMMAND_DISPLAY_CHARS]) if isinstance(command, str) else None
123
+ return {
124
+ "command": shown,
125
+ "ours": hooksjson.is_ours(entry),
126
+ "has_matcher": "matcher" in entry,
127
+ "fail_closed": entry.get("failClosed") is True,
128
+ }
129
+
130
+
131
+ def read_hooks_source(level: str, path: Path | None, note: str | None = None) -> HooksSource: # noqa: PLR0911
132
+ """Read one hooks.json level without ever raising."""
133
+ if path is None:
134
+ return HooksSource(level, None, exists=False, readable=False, note=note)
135
+ source = HooksSource(level, safe_text(str(path)), exists=False, readable=False, note=note)
136
+ try:
137
+ if not path.exists():
138
+ return source
139
+ except OSError as exc:
140
+ source.error = f"cannot stat: {exc.strerror or exc}"
141
+ return source
142
+ source.exists = True
143
+ try:
144
+ if path.stat().st_size > 1024 * 1024:
145
+ source.error = "file is larger than 1 MiB; not parsed"
146
+ return source
147
+ text = path.read_text(encoding="utf-8")
148
+ except (OSError, UnicodeDecodeError) as exc:
149
+ source.error = f"cannot read: {getattr(exc, 'strerror', None) or type(exc).__name__}"
150
+ return source
151
+ source.readable = True
152
+ try:
153
+ parsed = hooksjson.effective_events(text)
154
+ except hooksjson.HooksJsonError as exc:
155
+ source.error = safe_text(str(exc))
156
+ return source
157
+ source.events = {
158
+ # Event names come from a repository-controlled file: escape them for the terminal.
159
+ safe_text(event)[:120]: [_summarize_entry(e) for e in entries[:20]]
160
+ for event, entries in sorted(parsed.items())
161
+ }
162
+ return source
163
+
164
+
165
+ def probe_cursor_version(env: Mapping[str, str], *, probe: bool) -> tuple[str | None, str]:
166
+ """Return ``(version, how)``. Never fabricates: ``None`` when not discoverable."""
167
+ env_version = env.get("CURSOR_VERSION", "").strip()
168
+ if _VERSION_RE.match(env_version):
169
+ return env_version, "environment variable CURSOR_VERSION"
170
+ if not probe:
171
+ return None, "probe disabled"
172
+ exe = find_executable("cursor") # never the current directory (hostile repositories)
173
+ if exe is None:
174
+ return None, "the `cursor` command is not on PATH"
175
+ try:
176
+ proc = subprocess.run( # noqa: S603
177
+ [exe, "--version"],
178
+ capture_output=True,
179
+ text=True,
180
+ timeout=CURSOR_PROBE_TIMEOUT_S,
181
+ check=False,
182
+ )
183
+ except (subprocess.TimeoutExpired, OSError):
184
+ return None, "`cursor --version` failed or timed out"
185
+ first = (proc.stdout.strip().splitlines() or [""])[0].strip()
186
+ if proc.returncode == 0 and _VERSION_RE.match(first):
187
+ return first, "`cursor --version`"
188
+ return None, "`cursor --version` did not print a recognizable version"
189
+
190
+
191
+ def _check_runtime_permissions(runtime: Path) -> Check:
192
+ reason = untrusted_reason(str(runtime))
193
+ if reason is not None:
194
+ return Check(
195
+ "runtime.permissions",
196
+ "fail",
197
+ f"the runtime directory {reason}; CursorFleet refuses to read or write it",
198
+ f"Inspect it, then remove it (`rm -rf {safe_text(str(runtime))}`) and let the next "
199
+ "hook run recreate it as a private directory.",
200
+ )
201
+ if os.name == "nt":
202
+ return Check(
203
+ "runtime.permissions",
204
+ "info",
205
+ "permission bits are not checked on Windows (best effort ACLs; see ADR 0002)",
206
+ )
207
+ if not runtime.exists():
208
+ return Check(
209
+ "runtime.permissions",
210
+ "info",
211
+ "runtime directory does not exist yet; the first hook run creates it (0700/0600)",
212
+ )
213
+ loose: list[str] = []
214
+ scanned = 0
215
+ try:
216
+ for dirpath, dirnames, filenames in os.walk(runtime, followlinks=False):
217
+ for name in [".", *dirnames, *filenames]:
218
+ full = Path(dirpath) if name == "." else Path(dirpath) / name
219
+ scanned += 1
220
+ if scanned > MAX_PERMISSION_SCAN:
221
+ break
222
+ info = full.lstat()
223
+ if stat.S_ISLNK(info.st_mode):
224
+ continue
225
+ mode = stat.S_IMODE(info.st_mode)
226
+ if mode & 0o077:
227
+ loose.append(f"{full.relative_to(runtime).as_posix() or '.'} ({mode:04o})")
228
+ except OSError as exc:
229
+ return Check(
230
+ "runtime.permissions",
231
+ "warn",
232
+ f"could not inspect the runtime directory: {exc.strerror or exc}",
233
+ )
234
+ if loose:
235
+ return Check(
236
+ "runtime.permissions",
237
+ "fail",
238
+ f"{len(loose)} runtime path(s) are accessible by other users",
239
+ f"Run: chmod -R go-rwx {safe_text(str(runtime))}",
240
+ {"loose": [safe_text(x) for x in loose[:20]]},
241
+ )
242
+ return Check(
243
+ "runtime.permissions", "ok", "runtime directory is private (no group/other access)"
244
+ )
245
+
246
+
247
+ def _hook_shadows(root: Path) -> list[str]:
248
+ """Top-level files of ``root`` called ``cursorfleet-hook`` or ``cursorfleet-hook.*``."""
249
+ try:
250
+ with os.scandir(root) as entries:
251
+ return sorted(
252
+ e.name
253
+ for e in entries
254
+ if e.name.lower() == HOOK_BINARY or e.name.lower().startswith(HOOK_BINARY + ".")
255
+ )
256
+ except OSError:
257
+ return []
258
+
259
+
260
+ def _hooks_checks(sources: list[HooksSource], ws_known: bool, installed_hooks: bool) -> list[Check]:
261
+ checks: list[Check] = []
262
+ forbidden: list[str] = []
263
+ for source in sources:
264
+ for event, entries in source.events.items():
265
+ if event in FORBIDDEN_HOOKS and entries:
266
+ forbidden.append(f"{source.level}:{event}")
267
+ if source.error:
268
+ checks.append(
269
+ Check(
270
+ f"hooks.{source.level}",
271
+ "warn",
272
+ f"{source.level} hooks.json could not be read: {source.error}",
273
+ "Fix or remove the file; Cursor may also ignore it.",
274
+ )
275
+ )
276
+ if forbidden:
277
+ checks.append(
278
+ Check(
279
+ "hooks.forbidden",
280
+ "warn",
281
+ "content-bearing hooks are registered by other sources: " + ", ".join(forbidden),
282
+ "CursorFleet never registers these (ADR 0004) and cannot remove them. They "
283
+ "receive prompts, thinking, responses or file contents; review who added them.",
284
+ {"registered": forbidden},
285
+ )
286
+ )
287
+ else:
288
+ checks.append(
289
+ Check("hooks.forbidden", "ok", "no content-bearing hooks found at readable levels")
290
+ )
291
+ foreign = sum(
292
+ 1 for s in sources for entries in s.events.values() for e in entries if not e["ours"]
293
+ )
294
+ checks.append(
295
+ Check(
296
+ "hooks.effective",
297
+ "info",
298
+ f"{foreign} non-CursorFleet hook entr{'y' if foreign == 1 else 'ies'} visible "
299
+ "locally; team and third-party hooks cannot be listed from disk",
300
+ None,
301
+ {"foreign_entries": foreign},
302
+ )
303
+ )
304
+ if ws_known and installed_hooks:
305
+ project = next((s for s in sources if s.level == "project"), None)
306
+ ours = sum(1 for e in (project.events if project else {}).values() for x in e if x["ours"])
307
+ if ours == 0:
308
+ checks.append(
309
+ Check(
310
+ "hooks.project",
311
+ "fail",
312
+ "the install lockfile lists hooks but .cursor/hooks.json has none of ours",
313
+ "Run `cursorfleet init --cursor` to restore them.",
314
+ )
315
+ )
316
+ return checks
317
+
318
+
319
+ def run_doctor( # noqa: PLR0912, PLR0913, PLR0915
320
+ path: Path,
321
+ *,
322
+ home: Path | None = None,
323
+ platform: str | None = None,
324
+ env: Mapping[str, str] | None = None,
325
+ probe_cursor: bool = True,
326
+ enterprise_path: Path | None = None,
327
+ ) -> DoctorReport:
328
+ """Run all checks for the workspace containing ``path``."""
329
+ env = os.environ if env is None else env
330
+ platform = sys.platform if platform is None else platform
331
+ home = Path.home() if home is None else home
332
+ checks: list[Check] = []
333
+
334
+ # Python
335
+ py = sys.version_info
336
+ if (py.major, py.minor) >= MIN_PYTHON:
337
+ checks.append(Check("python.version", "ok", f"Python {py.major}.{py.minor}.{py.micro}"))
338
+ else:
339
+ checks.append(
340
+ Check(
341
+ "python.version",
342
+ "fail",
343
+ f"Python {py.major}.{py.minor} is too old (need >= 3.11)",
344
+ "Install Python 3.11 or newer and reinstall cursorfleet.",
345
+ )
346
+ )
347
+
348
+ # Hook binary
349
+ exe = shutil.which(HOOK_BINARY)
350
+ if exe:
351
+ checks.append(Check("hook.binary", "ok", f"{HOOK_BINARY} found", None, {"path": exe}))
352
+ else:
353
+ checks.append(
354
+ Check(
355
+ "hook.binary",
356
+ "fail",
357
+ f"{HOOK_BINARY} is not on PATH, so registered hooks cannot record anything",
358
+ "Install CursorFleet where Cursor's shell can see it, e.g. `uv tool install "
359
+ "cursorfleet` or `pipx install cursorfleet`, then restart Cursor. Hooks fail "
360
+ "open, so nothing is blocked meanwhile.",
361
+ )
362
+ )
363
+
364
+ # Git and runtime dir (Task 16 file-walk; never writes)
365
+ resolution = inspect_repository(str(path))
366
+ ws: Workspace | None
367
+ if resolution.status == "ok" and resolution.top_level and resolution.common_dir:
368
+ ws = Workspace(root=Path(resolution.top_level), common_dir=Path(resolution.common_dir))
369
+ checks.append(
370
+ Check(
371
+ "git.repo",
372
+ "ok",
373
+ safe_text(resolution.reason),
374
+ None,
375
+ {
376
+ "root": safe_text(str(ws.root)),
377
+ "common_dir": safe_text(str(ws.common_dir)),
378
+ "boundary": resolution.boundary,
379
+ },
380
+ )
381
+ )
382
+ checks.append(
383
+ Check(
384
+ "runtime.dir",
385
+ "ok",
386
+ f"runtime directory resolves to {safe_text(str(ws.runtime_dir))}",
387
+ None,
388
+ {"path": safe_text(str(ws.runtime_dir)), "exists": ws.runtime_dir.exists()},
389
+ )
390
+ )
391
+ checks.append(_check_runtime_permissions(ws.runtime_dir))
392
+ else:
393
+ ws = None
394
+ checks.append(
395
+ Check(
396
+ "git.repo",
397
+ "fail",
398
+ safe_text(resolution.reason),
399
+ "Run inside a git repository (v0.1 requires git; hooks record nothing "
400
+ "outside one), or pass --path.",
401
+ {"boundary": resolution.boundary},
402
+ )
403
+ )
404
+ root = ws.root if ws else path
405
+
406
+ # A file named like our hook inside the repo can shadow it where the shell searches the
407
+ # current directory first (Windows cmd.exe; hooks run from the project root).
408
+ shadows = _hook_shadows(root)
409
+ if shadows:
410
+ checks.append(
411
+ Check(
412
+ "hook.shadow",
413
+ "warn",
414
+ "the repository contains a file named like the hook command: "
415
+ + ", ".join(safe_text(x) for x in shadows[:5]),
416
+ "On Windows the shell may run it instead of the installed `cursorfleet-hook`. "
417
+ "Remove or rename it unless you put it there on purpose.",
418
+ {"files": [safe_text(x) for x in shadows[:5]]},
419
+ )
420
+ )
421
+
422
+ # Config
423
+ cfg_problems: list[str] = []
424
+ config, _roster = load_inputs(root, cfg_problems)
425
+ if cfg_problems:
426
+ checks.append(
427
+ Check(
428
+ "config.valid",
429
+ "fail",
430
+ "; ".join(safe_text(p) for p in cfg_problems[:3]),
431
+ "Run `cursorfleet validate` for details.",
432
+ )
433
+ )
434
+ else:
435
+ checks.append(Check("config.valid", "ok", "config and roster load (or defaults apply)"))
436
+
437
+ # Lockfile and drift
438
+ lock_problems: list[str] = []
439
+ lock = read_lock(root, lock_problems)
440
+ if lock_problems:
441
+ checks.append(
442
+ Check(
443
+ "install.lock",
444
+ "fail",
445
+ safe_text(lock_problems[0]),
446
+ f"Delete {LOCK_PATH} and re-run `cursorfleet init --cursor`.",
447
+ )
448
+ )
449
+ elif lock is None:
450
+ checks.append(
451
+ Check(
452
+ "install.lock",
453
+ "warn",
454
+ "the CursorFleet kit is not installed in this repository",
455
+ "Run `cursorfleet init --cursor --dry-run` to preview, then without --dry-run.",
456
+ )
457
+ )
458
+ else:
459
+ try:
460
+ items = check_drift(root, lock)
461
+ except UnsafePathError as exc: # pragma: no cover - defensive
462
+ items = []
463
+ checks.append(Check("install.drift", "fail", safe_text(str(exc))))
464
+ problems = [i for i in items if i.is_problem]
465
+ edited = [i.path for i in items if i.status == "edited"]
466
+ if problems:
467
+ checks.append(
468
+ Check(
469
+ "install.drift",
470
+ "fail",
471
+ f"{len(problems)} installed item(s) drifted from the lockfile",
472
+ "Review with `git diff`. If unintended, re-run `cursorfleet init --cursor` "
473
+ "(it refuses on modified files; restore them first). Agents can edit these "
474
+ "files; v0.1 detects but does not prevent that.",
475
+ {
476
+ "drift": [
477
+ {"path": i.path, "kind": i.kind, "status": i.status, "detail": i.detail}
478
+ for i in problems
479
+ ]
480
+ },
481
+ )
482
+ )
483
+ else:
484
+ checks.append(
485
+ Check(
486
+ "install.drift",
487
+ "ok",
488
+ f"{len(items)} installed item(s) match the lockfile",
489
+ None,
490
+ {"user_edited": edited},
491
+ )
492
+ )
493
+
494
+ # Effective hooks across levels
495
+ ent = (
496
+ enterprise_path
497
+ if enterprise_path is not None
498
+ else Path(str(enterprise_hooks_path(platform, env)))
499
+ )
500
+ sources = [
501
+ read_hooks_source("enterprise", ent),
502
+ HooksSource(
503
+ "team",
504
+ None,
505
+ exists=False,
506
+ readable=False,
507
+ note="cloud-distributed (Enterprise plan); not visible from disk",
508
+ ),
509
+ read_hooks_source("project", root / HOOKS_PATH),
510
+ read_hooks_source("user", home / ".cursor" / "hooks.json"),
511
+ ]
512
+ checks.extend(_hooks_checks(sources, ws is not None, bool(lock and lock.hooks)))
513
+
514
+ # Cursor version
515
+ version, how = probe_cursor_version(env, probe=probe_cursor)
516
+ validated = config.cursor.validated_versions
517
+ if version is None:
518
+ checks.append(
519
+ Check(
520
+ "cursor.version",
521
+ "warn",
522
+ f"Cursor version not discoverable ({how}); behavior is unverified (PROVISIONAL)",
523
+ "Hook events record cursor_version once they run. Add versions you have "
524
+ "verified to [cursor].validated_versions in .cursorfleet/config.toml.",
525
+ )
526
+ )
527
+ elif version in validated:
528
+ checks.append(Check("cursor.version", "ok", f"Cursor {version} is in validated_versions"))
529
+ else:
530
+ checks.append(
531
+ Check(
532
+ "cursor.version",
533
+ "warn",
534
+ f"Cursor {version} (from {how}) has not been verified with CursorFleet "
535
+ "(PROVISIONAL: no live capture reviewed)",
536
+ "Review a capture (spike/README.md), then add it to [cursor].validated_versions.",
537
+ {"validated_versions": list(validated)},
538
+ )
539
+ )
540
+
541
+ return DoctorReport(
542
+ checks=checks,
543
+ hooks=sources,
544
+ cursor_version=version,
545
+ runtime_dir=safe_text(str(ws.runtime_dir)) if ws else None,
546
+ resolution=resolution,
547
+ )
@@ -0,0 +1,156 @@
1
+ """Small filesystem helpers for the installer: hashing, safe printing, atomic writes.
2
+
3
+ Everything here is install-time only (never on the hook hot path).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import hashlib
10
+ import os
11
+ import stat
12
+ import tempfile
13
+ from pathlib import Path
14
+
15
+ DEFAULT_FILE_MODE = 0o644
16
+ MAX_FILE_BYTES = 1024 * 1024
17
+
18
+
19
+ class UnsafePathError(ValueError):
20
+ """A managed path would leave the workspace or goes through a symlink."""
21
+
22
+
23
+ _REPARSE_POINT = 0x400 # FILE_ATTRIBUTE_REPARSE_POINT: symlinks and junctions on Windows
24
+
25
+
26
+ def is_link_like(path: Path) -> bool:
27
+ """True for a symlink or, on Windows, any reparse point (junctions included).
28
+
29
+ ``Path.is_symlink`` misses NTFS junctions, which redirect a directory just as well.
30
+ """
31
+ try:
32
+ info = path.lstat()
33
+ except OSError:
34
+ return False
35
+ return stat.S_ISLNK(info.st_mode) or bool(
36
+ getattr(info, "st_file_attributes", 0) & _REPARSE_POINT
37
+ )
38
+
39
+
40
+ def normalize_newlines(text: str) -> str:
41
+ return text.replace("\r\n", "\n")
42
+
43
+
44
+ def sha256_text(text: str) -> str:
45
+ """SHA-256 hex of ``text`` with CRLF normalized to LF.
46
+
47
+ Normalizing keeps drift detection stable when git converts line endings.
48
+ """
49
+ return hashlib.sha256(normalize_newlines(text).encode("utf-8")).hexdigest()
50
+
51
+
52
+ def sha256_bytes(data: bytes) -> str:
53
+ return hashlib.sha256(data).hexdigest()
54
+
55
+
56
+ def safe_text(text: str) -> str:
57
+ """Escape control and bidi characters so repo content cannot drive the terminal.
58
+
59
+ Newlines and tabs pass through; everything else non-printable becomes ``\\uXXXX``.
60
+ """
61
+ out: list[str] = []
62
+ for ch in text:
63
+ if ch in "\n\t" or ch.isprintable():
64
+ out.append(ch)
65
+ elif ch == "\r":
66
+ out.append("\\r")
67
+ else:
68
+ out.append(f"\\u{ord(ch):04x}")
69
+ return "".join(out)
70
+
71
+
72
+ def read_text(path: Path) -> str | None:
73
+ """Return file text, ``None`` if absent. Raises ``ValueError`` if unreadable as UTF-8.
74
+
75
+ Reads at most ``MAX_FILE_BYTES``; larger files are refused rather than truncated.
76
+ """
77
+ try:
78
+ if is_link_like(path):
79
+ msg = f"{path.name} is a symlink"
80
+ raise UnsafePathError(msg)
81
+ if not path.exists():
82
+ return None
83
+ if not path.is_file():
84
+ msg = f"{path.name} is not a regular file"
85
+ raise ValueError(msg)
86
+ if path.stat().st_size > MAX_FILE_BYTES:
87
+ msg = f"{path.name} is larger than {MAX_FILE_BYTES} bytes"
88
+ raise ValueError(msg)
89
+ return path.read_bytes().decode("utf-8")
90
+ except UnicodeDecodeError as exc:
91
+ msg = f"{path.name} is not valid UTF-8"
92
+ raise ValueError(msg) from exc
93
+ except OSError as exc:
94
+ msg = f"cannot read {path.name}: {exc.strerror or exc}"
95
+ raise ValueError(msg) from exc
96
+
97
+
98
+ def ensure_inside(root: Path, rel: str) -> Path:
99
+ """Resolve workspace-relative ``rel`` under ``root``; refuse escapes and symlinks.
100
+
101
+ Every existing component (including the target) must not be a symlink, so a
102
+ malicious repository cannot redirect a managed write elsewhere.
103
+ """
104
+ base = root.resolve()
105
+ current = base
106
+ for part in rel.split("/"):
107
+ current = current / part
108
+ if is_link_like(current):
109
+ msg = f"refusing to follow symlink at {rel!r}"
110
+ raise UnsafePathError(msg)
111
+ target = base.joinpath(*rel.split("/"))
112
+ try:
113
+ target.resolve().relative_to(base)
114
+ except ValueError as exc:
115
+ msg = f"path {rel!r} escapes the workspace"
116
+ raise UnsafePathError(msg) from exc
117
+ return target
118
+
119
+
120
+ def atomic_write_text(path: Path, text: str) -> None:
121
+ """Write ``text`` (UTF-8, no newline translation) to ``path`` atomically.
122
+
123
+ Preserves the permission bits of an existing file; new files get 0644.
124
+ """
125
+ path.parent.mkdir(parents=True, exist_ok=True)
126
+ mode = DEFAULT_FILE_MODE
127
+ with contextlib.suppress(OSError):
128
+ mode = path.stat().st_mode & 0o777
129
+ fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
130
+ try:
131
+ with os.fdopen(fd, "wb") as handle:
132
+ handle.write(text.encode("utf-8"))
133
+ handle.flush()
134
+ os.fsync(handle.fileno())
135
+ with contextlib.suppress(OSError):
136
+ os.chmod(tmp_name, mode)
137
+ os.replace(tmp_name, path)
138
+ except BaseException:
139
+ with contextlib.suppress(OSError):
140
+ os.unlink(tmp_name)
141
+ raise
142
+
143
+
144
+ def remove_empty_dirs(root: Path, rel_dirs: list[str]) -> list[str]:
145
+ """Remove each directory in ``rel_dirs`` (deepest first) when empty. Return removed ones."""
146
+ removed: list[str] = []
147
+ for rel in sorted(set(rel_dirs), key=lambda d: (-d.count("/"), d)):
148
+ target = root.joinpath(*rel.split("/"))
149
+ if is_link_like(target) or not target.is_dir():
150
+ continue
151
+ try:
152
+ target.rmdir()
153
+ except OSError:
154
+ continue
155
+ removed.append(rel)
156
+ return removed