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,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
|