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