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,365 @@
1
+ """``cursorfleet validate``: check config, roster, generated kit and artifacts.
2
+
3
+ Read-only. Findings about files CursorFleet generated are errors; findings
4
+ about user-owned files (their own rules or agents) are warnings, because we
5
+ cannot know what Cursor accepts beyond what the docs say. PROVISIONAL: the
6
+ allowed frontmatter keys come from the Cursor docs (ADR 0001 A7), not a live run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ from dataclasses import dataclass
13
+ from pathlib import Path, PurePosixPath
14
+ from typing import Literal
15
+
16
+ from pydantic import ValidationError
17
+
18
+ from cursorfleet.adapters.cursor import blocks
19
+ from cursorfleet.adapters.cursor.fsutil import (
20
+ UnsafePathError,
21
+ ensure_inside,
22
+ is_link_like,
23
+ read_text,
24
+ sha256_text,
25
+ )
26
+ from cursorfleet.adapters.cursor.installer import check_drift, read_lock
27
+ from cursorfleet.adapters.cursor.kit import (
28
+ AGENTS_DIR,
29
+ CONFIG_PATH,
30
+ LOCK_PATH,
31
+ ROSTER_PATH,
32
+ RULES_DIR,
33
+ SKILLS_DIR,
34
+ SUBAGENT_FRONTMATTER_KEYS,
35
+ build_kit,
36
+ )
37
+ from cursorfleet.config.io import loads_config, loads_roster
38
+ from cursorfleet.config.models import FleetConfig
39
+ from cursorfleet.config.roster import Roster, default_roster
40
+ from cursorfleet.workflow.artifacts import MAX_ARTIFACT_BYTES, validate_artifact_text
41
+ from cursorfleet.workflow.frontmatter import FrontmatterError, parse_frontmatter
42
+
43
+ Severity = Literal["error", "warning"]
44
+ RULE_KEYS: frozenset[str] = frozenset({"description", "globs", "alwaysApply"})
45
+ SKILL_KEYS: frozenset[str] = frozenset({"name", "description"})
46
+ MAX_RULE_LINES = 500 # rules must stay strictly under this
47
+ MAX_ARTIFACT_FILES = 2000
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Finding:
52
+ severity: Severity
53
+ code: str
54
+ message: str
55
+ path: str | None = None
56
+ hint: str | None = None
57
+
58
+ def as_dict(self) -> dict[str, str | None]:
59
+ return {
60
+ "severity": self.severity,
61
+ "code": self.code,
62
+ "path": self.path,
63
+ "message": self.message,
64
+ "hint": self.hint,
65
+ }
66
+
67
+
68
+ def _first_line(exc: Exception) -> str:
69
+ return (str(exc).strip().splitlines() or ["error"])[0][:200]
70
+
71
+
72
+ class _Validator:
73
+ def __init__(self, root: Path) -> None:
74
+ self.root = root
75
+ self.findings: list[Finding] = []
76
+
77
+ def add(
78
+ self,
79
+ severity: Severity,
80
+ code: str,
81
+ message: str,
82
+ path: str | None = None,
83
+ hint: str | None = None,
84
+ ) -> None:
85
+ self.findings.append(Finding(severity, code, message, path, hint))
86
+
87
+ def read(self, rel: str) -> str | None:
88
+ try:
89
+ return read_text(ensure_inside(self.root, rel))
90
+ except (UnsafePathError, ValueError) as exc:
91
+ self.add("error", "file.unreadable", _first_line(exc), rel)
92
+ return None
93
+
94
+ # -- config and roster ---------------------------------------------------------
95
+
96
+ def load_config(self) -> FleetConfig | None:
97
+ text = self.read(CONFIG_PATH)
98
+ if text is None:
99
+ return FleetConfig() if not self._has(CONFIG_PATH) else None
100
+ try:
101
+ return loads_config(text)
102
+ except (ValueError, ValidationError) as exc:
103
+ self.add(
104
+ "error",
105
+ "config.invalid",
106
+ _first_line(exc),
107
+ CONFIG_PATH,
108
+ "Fix the value or delete the file and re-run `cursorfleet init --cursor`.",
109
+ )
110
+ return None
111
+
112
+ def load_roster(self) -> Roster | None:
113
+ text = self.read(ROSTER_PATH)
114
+ if text is None:
115
+ return default_roster() if not self._has(ROSTER_PATH) else None
116
+ try:
117
+ return loads_roster(text)
118
+ except (ValueError, ValidationError) as exc:
119
+ self.add(
120
+ "error",
121
+ "roster.invalid",
122
+ _first_line(exc),
123
+ ROSTER_PATH,
124
+ "Fix the roster (ids are unique slugs; the coordinator is not a subagent).",
125
+ )
126
+ return None
127
+
128
+ def _has(self, rel: str) -> bool:
129
+ return self.root.joinpath(*rel.split("/")).exists()
130
+
131
+ def check_roster(self, roster: Roster) -> None:
132
+ names = [roster.cursor_name(a.id) for a in roster.agents]
133
+ if len(set(names)) != len(names):
134
+ self.add("error", "roster.duplicate_names", "two agents share a Cursor subagent name")
135
+ if not roster.enabled_agents():
136
+ self.add("warning", "roster.empty", "no subagent is enabled", ROSTER_PATH)
137
+ coordinator_file = f"{AGENTS_DIR}/{roster.cursor_name('coordinator')}.md"
138
+ if self._has(coordinator_file):
139
+ self.add(
140
+ "error",
141
+ "roster.coordinator_as_subagent",
142
+ "the coordinator must be the main agent (a skill), not a subagent file",
143
+ coordinator_file,
144
+ "Delete the file; the cursorfleet-coordinator skill delivers the coordinator.",
145
+ )
146
+
147
+ # -- generated kit -------------------------------------------------------------
148
+
149
+ def check_kit(self, roster: Roster, config: FleetConfig) -> set[str]: # noqa: PLR0912
150
+ """Compare disk with the rendered kit. Returns the set of kit-owned file paths."""
151
+ lock_errors: list[str] = []
152
+ lock = read_lock(self.root, lock_errors)
153
+ for err in lock_errors:
154
+ self.add("error", "lock.invalid", err, LOCK_PATH)
155
+ kit = build_kit(roster, config)
156
+ installed = lock is not None or any(self._has(p) for p in kit.files)
157
+ if not installed:
158
+ self.add(
159
+ "warning",
160
+ "kit.not_installed",
161
+ "the CursorFleet kit is not installed",
162
+ None,
163
+ "Run `cursorfleet init --cursor`.",
164
+ )
165
+ return set(kit.files)
166
+ for rel, desired in kit.files.items():
167
+ disk = self.read(rel)
168
+ if disk is None:
169
+ if not any(f.path == rel and f.code == "file.unreadable" for f in self.findings):
170
+ self.add(
171
+ "error",
172
+ "kit.missing",
173
+ "generated file is missing",
174
+ rel,
175
+ "Run `cursorfleet init --cursor` to regenerate it.",
176
+ )
177
+ elif sha256_text(disk) != sha256_text(desired):
178
+ self.add(
179
+ "error",
180
+ "kit.drift",
181
+ "file differs from what the templates and roster generate",
182
+ rel,
183
+ "Edit the roster instead of the file, then re-run `cursorfleet init --cursor`.",
184
+ )
185
+ for rel, block in kit.blocks.items():
186
+ disk = self.read(rel)
187
+ try:
188
+ current = blocks.block_text(disk) if disk is not None else None
189
+ except blocks.BlockError as exc:
190
+ self.add("error", "kit.block_malformed", str(exc), rel)
191
+ continue
192
+ if current is None:
193
+ self.add("error", "kit.block_missing", "managed block is missing", rel)
194
+ elif sha256_text(current) != sha256_text(block):
195
+ self.add("error", "kit.block_drift", "managed block differs from template", rel)
196
+ if lock is not None:
197
+ for item in check_drift(self.root, lock):
198
+ if item.is_problem and item.kind == "hooks":
199
+ self.add("error", "kit.hooks_drift", item.detail, item.path)
200
+ for entry in lock.files:
201
+ if entry.kind == "file" and entry.path not in kit.files:
202
+ self.add(
203
+ "warning",
204
+ "kit.orphan",
205
+ "installed file is no longer generated by the roster",
206
+ entry.path,
207
+ "Re-run `cursorfleet init --cursor` to remove it.",
208
+ )
209
+ return set(kit.files)
210
+
211
+ # -- frontmatter checks --------------------------------------------------------
212
+
213
+ def check_agents(self, roster: Roster, kit_files: set[str]) -> None:
214
+ base = self.root / AGENTS_DIR
215
+ if not base.is_dir() or is_link_like(base):
216
+ return
217
+ for entry in sorted(base.iterdir(), key=lambda p: p.name):
218
+ rel = f"{AGENTS_DIR}/{entry.name}"
219
+ if rel not in kit_files and not entry.name.startswith(roster.name_prefix or "\0"):
220
+ continue
221
+ text = self.read(rel)
222
+ if text is None:
223
+ continue
224
+ try:
225
+ meta = parse_frontmatter(text).meta
226
+ except FrontmatterError as exc:
227
+ self.add("error", "agent.frontmatter", str(exc), rel)
228
+ continue
229
+ extra = sorted(set(meta) - set(SUBAGENT_FRONTMATTER_KEYS))
230
+ if extra:
231
+ self.add(
232
+ "error",
233
+ "agent.frontmatter_key",
234
+ f"unsupported subagent frontmatter keys: {', '.join(extra)}",
235
+ rel,
236
+ f"Cursor supports only: {', '.join(SUBAGENT_FRONTMATTER_KEYS)}.",
237
+ )
238
+ for key in ("name", "description"):
239
+ if not isinstance(meta.get(key), str) or not meta.get(key):
240
+ self.add("error", "agent.frontmatter_missing", f"missing {key}", rel)
241
+ for key in ("readonly", "is_background"):
242
+ if key in meta and not isinstance(meta[key], bool):
243
+ self.add("error", "agent.frontmatter_type", f"{key} must be true/false", rel)
244
+ if meta.get("name") != PurePosixPath(entry.name).stem:
245
+ self.add("error", "agent.name_mismatch", "name must equal the file name", rel)
246
+
247
+ def check_rules(self, kit_files: set[str]) -> None:
248
+ base = self.root / RULES_DIR
249
+ if not base.is_dir() or is_link_like(base):
250
+ return
251
+ for dirpath, dirnames, filenames in os.walk(base, followlinks=False):
252
+ dirnames.sort()
253
+ for name in sorted(filenames):
254
+ full = Path(dirpath) / name
255
+ rel = full.relative_to(self.root).as_posix()
256
+ ours = rel in kit_files
257
+ sev: Severity = "error" if ours else "warning"
258
+ if full.suffix != ".mdc":
259
+ self.add(
260
+ sev,
261
+ "rules.extension",
262
+ "rule files must be .mdc; Cursor ignores other extensions here",
263
+ rel,
264
+ "Rename to .mdc and add frontmatter.",
265
+ )
266
+ continue
267
+ self._check_rule(rel, sev)
268
+
269
+ def _check_rule(self, rel: str, sev: Severity) -> None:
270
+ text = self.read(rel)
271
+ if text is None:
272
+ return
273
+ if len(text.splitlines()) >= MAX_RULE_LINES:
274
+ self.add(sev, "rules.too_long", f"rule has {MAX_RULE_LINES} or more lines", rel)
275
+ try:
276
+ meta = parse_frontmatter(text).meta
277
+ except FrontmatterError as exc:
278
+ self.add(sev, "rules.frontmatter", str(exc), rel, "Add a `---` frontmatter block.")
279
+ return
280
+ extra = sorted(set(meta) - RULE_KEYS)
281
+ if extra:
282
+ self.add(
283
+ "warning",
284
+ "rules.frontmatter_key",
285
+ f"unrecognized rule keys: {', '.join(extra)}",
286
+ rel,
287
+ )
288
+ if "alwaysApply" in meta and not isinstance(meta["alwaysApply"], bool):
289
+ self.add(sev, "rules.frontmatter_type", "alwaysApply must be true/false", rel)
290
+
291
+ def check_skills(self, kit_files: set[str]) -> None:
292
+ for rel in sorted(p for p in kit_files if p.startswith(SKILLS_DIR + "/")):
293
+ if not self._has(rel):
294
+ continue
295
+ text = self.read(rel)
296
+ if text is None:
297
+ continue
298
+ try:
299
+ meta = parse_frontmatter(text).meta
300
+ except FrontmatterError as exc:
301
+ self.add("error", "skill.frontmatter", str(exc), rel)
302
+ continue
303
+ extra = sorted(set(meta) - SKILL_KEYS)
304
+ if extra:
305
+ self.add("error", "skill.frontmatter_key", f"unsupported: {', '.join(extra)}", rel)
306
+ if meta.get("name") != PurePosixPath(rel).parent.name:
307
+ self.add("error", "skill.name_mismatch", "name must equal the directory", rel)
308
+ if not isinstance(meta.get("description"), str) or not meta.get("description"):
309
+ self.add("error", "skill.frontmatter_missing", "missing description", rel)
310
+
311
+ # -- artifacts -----------------------------------------------------------------
312
+
313
+ def check_artifacts(self, config: FleetConfig) -> int:
314
+ work_rel = config.work.dir
315
+ base = self.root.joinpath(*work_rel.split("/"))
316
+ if not base.is_dir() or is_link_like(base):
317
+ return 0
318
+ count = 0
319
+ for dirpath, dirnames, filenames in os.walk(base, followlinks=False):
320
+ dirnames.sort()
321
+ for name in sorted(filenames):
322
+ full = Path(dirpath) / name
323
+ inner = full.relative_to(base).as_posix()
324
+ rel = f"{work_rel}/{inner}"
325
+ if not name.endswith(".md") or name == "AGENTS.md":
326
+ continue
327
+ count += 1
328
+ if count > MAX_ARTIFACT_FILES:
329
+ self.add("warning", "artifact.too_many", "stopped after 2000 artifacts")
330
+ return count
331
+ if is_link_like(full):
332
+ self.add("warning", "artifact.symlink", "symlink skipped", rel)
333
+ continue
334
+ try:
335
+ if full.stat().st_size > MAX_ARTIFACT_BYTES:
336
+ self.add("error", "artifact.too_large", "artifact is too large", rel)
337
+ continue
338
+ except OSError:
339
+ continue
340
+ text = self.read(rel)
341
+ if text is None:
342
+ continue
343
+ for problem in validate_artifact_text(text, inner):
344
+ self.add("error", problem.code, problem.message, rel)
345
+ return count
346
+
347
+
348
+ def validate_workspace(root: Path) -> tuple[list[Finding], dict[str, int]]:
349
+ """Run every check. Returns ``(findings, stats)``."""
350
+ v = _Validator(root)
351
+ config = v.load_config()
352
+ roster = v.load_roster()
353
+ artifacts = 0
354
+ if roster is not None:
355
+ v.check_roster(roster)
356
+ if roster is not None and config is not None:
357
+ kit_files = v.check_kit(roster, config)
358
+ v.check_agents(roster, kit_files)
359
+ v.check_skills(kit_files)
360
+ v.check_rules(kit_files)
361
+ else:
362
+ v.check_rules(set())
363
+ artifacts = v.check_artifacts(config or FleetConfig())
364
+ stats = {"artifacts": artifacts}
365
+ return v.findings, stats
@@ -0,0 +1,80 @@
1
+ """Resolve the workspace root and the shared runtime directory via git (ADR 0002).
2
+
3
+ Every subprocess uses an argv list and an explicit timeout.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ from cursorfleet.git.runner import git_env
13
+ from cursorfleet.safeexe import find_executable
14
+
15
+ GIT_TIMEOUT_S = 10.0
16
+
17
+
18
+ class WorkspaceError(RuntimeError):
19
+ """The path is not inside a usable git repository."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Workspace:
24
+ root: Path # top level of the working tree (a worktree root inside a worktree)
25
+ common_dir: Path # absolute, realpath-resolved ``git rev-parse --git-common-dir``
26
+
27
+ @property
28
+ def runtime_dir(self) -> Path:
29
+ """``<git-common-dir>/cursorfleet`` (shared by all worktrees; PROVISIONAL, ADR 0002 Q4)."""
30
+ return self.common_dir / "cursorfleet"
31
+
32
+
33
+ def run_git(args: list[str], cwd: Path, *, timeout: float = GIT_TIMEOUT_S) -> str:
34
+ """Run ``git <args>`` read-only in ``cwd`` and return stdout. Raises ``WorkspaceError``."""
35
+ git = find_executable("git") # never the current directory (hostile repositories)
36
+ if git is None:
37
+ msg = "git was not found on PATH"
38
+ raise WorkspaceError(msg)
39
+ try:
40
+ proc = subprocess.run( # noqa: S603
41
+ [git, "--no-optional-locks", "-c", "core.fsmonitor=false", *args],
42
+ cwd=cwd,
43
+ capture_output=True,
44
+ text=True,
45
+ encoding="utf-8",
46
+ errors="replace",
47
+ timeout=timeout,
48
+ check=False,
49
+ env=git_env(),
50
+ stdin=subprocess.DEVNULL,
51
+ )
52
+ except subprocess.TimeoutExpired as exc:
53
+ msg = f"git {args[0]} timed out after {timeout:g}s"
54
+ raise WorkspaceError(msg) from exc
55
+ except OSError as exc:
56
+ msg = f"cannot run git: {exc.strerror or exc}"
57
+ raise WorkspaceError(msg) from exc
58
+ if proc.returncode != 0:
59
+ first = (proc.stderr.strip().splitlines() or ["unknown error"])[0]
60
+ msg = f"git {args[0]} failed: {first[:200]}"
61
+ raise WorkspaceError(msg)
62
+ return proc.stdout
63
+
64
+
65
+ def resolve_workspace(path: Path) -> Workspace:
66
+ """Return the :class:`Workspace` containing ``path``. Raises ``WorkspaceError``."""
67
+ start = path.resolve()
68
+ if not start.is_dir():
69
+ msg = f"{path} is not a directory"
70
+ raise WorkspaceError(msg)
71
+ out = run_git(["rev-parse", "--show-toplevel", "--git-common-dir"], start)
72
+ lines = out.splitlines()
73
+ if len(lines) != 2:
74
+ msg = "unexpected output from git rev-parse"
75
+ raise WorkspaceError(msg)
76
+ top = Path(lines[0])
77
+ common = Path(lines[1])
78
+ if not common.is_absolute():
79
+ common = start / common
80
+ return Workspace(root=top.resolve(), common_dir=common.resolve())
File without changes
@@ -0,0 +1 @@
1
+ """One module per command group; each exposes ``register(app: typer.Typer) -> None``."""
@@ -0,0 +1,82 @@
1
+ """Helpers shared by the kit commands (init, uninstall, doctor, validate)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ from pathlib import Path
8
+
9
+ import typer
10
+
11
+ from cursorfleet.adapters.cursor.fsutil import safe_text
12
+ from cursorfleet.adapters.cursor.installer import Plan, render_diff, summarize
13
+ from cursorfleet.adapters.cursor.workspace import WorkspaceError, resolve_workspace
14
+ from cursorfleet.state.runtime import find_git_location
15
+
16
+
17
+ def fail(message: str, code: int = 1) -> typer.Exit:
18
+ typer.echo(safe_text(message), err=True)
19
+ return typer.Exit(code)
20
+
21
+
22
+ def _looks_like_git_dir(path: Path) -> bool:
23
+ return (path / "HEAD").is_file() and (path / "objects").is_dir()
24
+
25
+
26
+ def _suggested_command(command: str, root: Path) -> str:
27
+ extra = " --cursor" if command == "init" else ""
28
+ return f"cd {shlex.quote(str(root))} && cursorfleet {command}{extra}"
29
+
30
+
31
+ def workspace_root(path: Path, *, command: str = "init") -> Path:
32
+ """Return ``path`` only when it is a Git repository root (ADR 0009).
33
+
34
+ Exits 2 and writes nothing when the target is not a Git working tree or is
35
+ an ordinary subdirectory. Does not read ``.cursorfleet/`` or build a plan.
36
+ """
37
+ try:
38
+ target = path.expanduser().resolve()
39
+ except OSError as exc:
40
+ raise fail(f"error: cannot resolve {path}.\nNothing was changed.", 2) from exc
41
+ if not target.is_dir():
42
+ raise fail(
43
+ f"error: {safe_text(str(target))} is not a directory.\nNothing was changed.",
44
+ 2,
45
+ )
46
+ try:
47
+ workspace = resolve_workspace(target)
48
+ except WorkspaceError as exc:
49
+ loc = find_git_location(str(target))
50
+ if loc is None and not _looks_like_git_dir(target):
51
+ raise fail(
52
+ f"error: Git is required. {safe_text(str(target))} is not inside a "
53
+ "Git working tree.\nNothing was changed. Run `git init` there, or run "
54
+ "this command from a repository root.",
55
+ 2,
56
+ ) from exc
57
+ first = str(exc).splitlines()[0][:200]
58
+ raise fail(
59
+ f"error: no usable working tree was found. {safe_text(first)}\nNothing was changed.",
60
+ 2,
61
+ ) from exc
62
+ target_key = os.path.normcase(os.path.realpath(str(target)))
63
+ root_key = os.path.normcase(os.path.realpath(str(workspace.root)))
64
+ if target_key != root_key:
65
+ raise fail(
66
+ f"error: {safe_text(str(target))} is not the repository root.\n"
67
+ f"Detected repository root: {safe_text(str(workspace.root))}\n"
68
+ f"Nothing was changed. Run: {_suggested_command(command, workspace.root)}",
69
+ 2,
70
+ )
71
+ return workspace.root
72
+
73
+
74
+ def show_plan(plan: Plan) -> None:
75
+ typer.echo(f"Workspace: {safe_text(str(plan.root))}")
76
+ typer.echo("Planned changes:")
77
+ for line in summarize(plan):
78
+ typer.echo(f" {line}")
79
+ for note in plan.notes:
80
+ typer.echo(f" note: {safe_text(note)}")
81
+ typer.echo("")
82
+ typer.echo(render_diff(plan), nl=False)
@@ -0,0 +1,56 @@
1
+ """Shared doctor/validate repository-resolution renderers (ADR 0009 task 15).
2
+
3
+ The facts come from ``inspect_repository`` (the Task 16 file-walk). This module
4
+ does not walk, does not write, and keeps JSON and plain text on the same mapping.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ from cursorfleet.adapters.cursor.fsutil import safe_text
12
+ from cursorfleet.state.runtime import Resolution, inspect_repository
13
+
14
+ RESOLUTION_KEYS = (
15
+ "input_path",
16
+ "resolved_path",
17
+ "repository_root",
18
+ "common_dir",
19
+ "marker_path",
20
+ "marker_present",
21
+ "marker_valid",
22
+ "boundary",
23
+ "status",
24
+ "reason",
25
+ )
26
+
27
+
28
+ def build_resolution(path: Path, workspace_roots: object = None) -> Resolution:
29
+ """Read-only classification of ``path``. Never creates files or directories."""
30
+ try:
31
+ start = str(path.expanduser())
32
+ except OSError:
33
+ start = str(path)
34
+ return inspect_repository(start, workspace_roots)
35
+
36
+
37
+ def resolution_as_dict(resolution: Resolution) -> dict[str, object]:
38
+ """Stable JSON object. ``render_resolution_text`` prints these same values."""
39
+ return resolution.as_dict()
40
+
41
+
42
+ def _shown(value: object) -> str:
43
+ if value is None:
44
+ return "null"
45
+ if isinstance(value, bool):
46
+ return "true" if value else "false"
47
+ return safe_text(str(value))
48
+
49
+
50
+ def render_resolution_text(resolution: Resolution) -> str:
51
+ """Plain-text block with the same facts as :func:`resolution_as_dict`."""
52
+ data = resolution.as_dict()
53
+ lines = ["Repository resolution:"]
54
+ for key in RESOLUTION_KEYS:
55
+ lines.append(f" {key}: {_shown(data[key])}")
56
+ return "\n".join(lines)
@@ -0,0 +1,81 @@
1
+ """``cursorfleet doctor``: environment, install and hook diagnostics (read-only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from cursorfleet.adapters.cursor.diagnostics import DoctorReport, run_doctor
12
+ from cursorfleet.adapters.cursor.fsutil import safe_text
13
+ from cursorfleet.cli.commands._resolution import render_resolution_text
14
+
15
+ _LABEL = {"ok": "ok ", "warn": "WARN", "fail": "FAIL", "info": "info"}
16
+
17
+
18
+ def _render(report: DoctorReport) -> str:
19
+ lines: list[str] = []
20
+ if report.resolution is not None:
21
+ lines.append(render_resolution_text(report.resolution))
22
+ lines.append("")
23
+ for check in report.checks:
24
+ lines.append(f"[{_LABEL[check.status]}] {check.id}: {safe_text(check.message)}")
25
+ if check.remediation and check.status in {"warn", "fail"}:
26
+ lines.append(f" fix: {safe_text(check.remediation)}")
27
+ if check.id == "install.drift":
28
+ for item in check.details.get("drift", []):
29
+ lines.append(f" - {safe_text(item['path'])}: {safe_text(item['detail'])}")
30
+ lines.append("")
31
+ lines.append("Effective hooks (Cursor merge order: enterprise, team, project, user):")
32
+ for source in report.hooks:
33
+ if source.path is None:
34
+ lines.append(f" {source.level:<10} {safe_text(source.note or 'n/a')}")
35
+ continue
36
+ state = "absent" if not source.exists else ("unreadable" if not source.readable else "ok")
37
+ total = sum(len(v) for v in source.events.values())
38
+ lines.append(f" {source.level:<10} {source.path} ({state}, {total} entries)")
39
+ if source.error:
40
+ lines.append(f" error: {source.error}")
41
+ for event, entries in source.events.items():
42
+ for entry in entries:
43
+ tag = "cursorfleet" if entry["ours"] else "other"
44
+ lines.append(f" {event}: [{tag}] {entry['command']}")
45
+ counts = report.counts()
46
+ lines.append("")
47
+ lines.append(
48
+ f"{counts['ok']} ok, {counts['warn']} warnings, {counts['fail']} problems"
49
+ + ("" if report.ok else " (exit 1)")
50
+ )
51
+ return "\n".join(lines)
52
+
53
+
54
+ def register(app: typer.Typer) -> None:
55
+ @app.command("doctor")
56
+ def doctor(
57
+ json_output: Annotated[
58
+ bool, typer.Option("--json", help="Machine-readable output.")
59
+ ] = False,
60
+ path: Annotated[
61
+ Path, typer.Option("--path", help="Directory inside the git repo (default: current).")
62
+ ] = Path(),
63
+ probe_cursor: Annotated[
64
+ bool,
65
+ typer.Option(
66
+ "--probe-cursor/--no-probe-cursor",
67
+ help="Run `cursor --version` (argv, 5s timeout) to report the Cursor version.",
68
+ ),
69
+ ] = True,
70
+ ) -> None:
71
+ """Check Python, the hook binary, git, runtime dir, install drift and effective hooks.
72
+
73
+ Exit code 0 when no check failed (warnings are fine), 1 otherwise.
74
+ """
75
+ report = run_doctor(path, probe_cursor=probe_cursor)
76
+ if json_output:
77
+ typer.echo(json.dumps(report.as_dict(), indent=2))
78
+ else:
79
+ typer.echo(_render(report))
80
+ if not report.ok:
81
+ raise typer.Exit(1)