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,305 @@
|
|
|
1
|
+
"""Merge CursorFleet's hook entries into ``.cursor/hooks.json`` and take them out again.
|
|
2
|
+
|
|
3
|
+
Design:
|
|
4
|
+
|
|
5
|
+
* Every registered hook runs exactly ``cursorfleet-hook`` with no arguments (the
|
|
6
|
+
hook name comes from ``hook_event_name`` on stdin) and a short ``timeout``.
|
|
7
|
+
There is no ``failClosed``: a missing binary or a crash must never block the
|
|
8
|
+
user (observe-only; fail open).
|
|
9
|
+
* Only hooks in ``ALLOWED_V01_HOOKS`` may be emitted. ``assert_registrable`` is
|
|
10
|
+
called on every code path that builds entries, so a forbidden hook name raises
|
|
11
|
+
before anything is written (ADR 0004).
|
|
12
|
+
* No ``matcher`` is written: every hook matches everything. Narrowing is
|
|
13
|
+
PROVISIONAL territory (ADR 0001 A3: matchers target tool types, subagent types
|
|
14
|
+
or command text, whose exact values for custom ``cf-*`` subagents are unverified),
|
|
15
|
+
and a wrong matcher would silently lose events. Revisit after live capture.
|
|
16
|
+
* User entries, other events, other top-level keys and key order are preserved.
|
|
17
|
+
Formatting (indent, separators, trailing newline) is detected and reproduced.
|
|
18
|
+
* Entries are identified as ours by ``command == "cursorfleet-hook"``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from collections.abc import Iterable, Mapping
|
|
25
|
+
from dataclasses import dataclass, field
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, ConfigDict
|
|
29
|
+
|
|
30
|
+
from cursorfleet.adapters.cursor.hook_policy import ALLOWED_V01_HOOKS, FORBIDDEN_HOOKS
|
|
31
|
+
from cursorfleet.adapters.cursor.templates import read_template
|
|
32
|
+
|
|
33
|
+
HOOK_COMMAND = "cursorfleet-hook"
|
|
34
|
+
HOOK_TIMEOUT_S = 5
|
|
35
|
+
SUPPORTED_VERSION = 1
|
|
36
|
+
Json = Any
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HooksJsonError(ValueError):
|
|
40
|
+
"""``hooks.json`` cannot be merged safely (invalid JSON or unexpected structure)."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class JsonStyle(BaseModel):
|
|
44
|
+
"""How a JSON file was formatted, so we can write it back the same way."""
|
|
45
|
+
|
|
46
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
47
|
+
|
|
48
|
+
indent: int | None = 2 # None = single line; -1 = tab
|
|
49
|
+
compact: bool = False # separators (",", ":") when indent is None
|
|
50
|
+
ensure_ascii: bool = False
|
|
51
|
+
trailing_newline: bool = True
|
|
52
|
+
crlf: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
DEFAULT_STYLE = JsonStyle()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def dumps(data: Json, style: JsonStyle) -> str:
|
|
59
|
+
indent: int | str | None = "\t" if style.indent == -1 else style.indent
|
|
60
|
+
separators = (",", ":") if style.indent is None and style.compact else None
|
|
61
|
+
if indent is not None:
|
|
62
|
+
separators = (",", ": ")
|
|
63
|
+
text = json.dumps(data, indent=indent, separators=separators, ensure_ascii=style.ensure_ascii)
|
|
64
|
+
text += "\n" if style.trailing_newline else ""
|
|
65
|
+
return text.replace("\n", "\r\n") if style.crlf else text
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _candidate_styles(trailing: bool) -> Iterable[JsonStyle]:
|
|
69
|
+
for ascii_only in (False, True):
|
|
70
|
+
for indent, compact in ((2, False), (4, False), (-1, False), (None, False), (None, True)):
|
|
71
|
+
yield JsonStyle(
|
|
72
|
+
indent=indent,
|
|
73
|
+
compact=compact,
|
|
74
|
+
ensure_ascii=ascii_only,
|
|
75
|
+
trailing_newline=trailing,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def detect_style(text: str, data: Json) -> tuple[JsonStyle, bool]:
|
|
80
|
+
"""Return ``(style, reproducible)``; reproducible means ``dumps(data, style) == text``."""
|
|
81
|
+
crlf = "\r\n" in text
|
|
82
|
+
normalized = text.replace("\r\n", "\n")
|
|
83
|
+
trailing = normalized.endswith("\n")
|
|
84
|
+
for candidate in _candidate_styles(trailing):
|
|
85
|
+
style = candidate.model_copy(update={"crlf": crlf})
|
|
86
|
+
if dumps(data, style) == text:
|
|
87
|
+
return style, True
|
|
88
|
+
return JsonStyle(trailing_newline=trailing, crlf=crlf), False
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def managed_entry() -> dict[str, Json]:
|
|
92
|
+
"""The single entry we register per hook, from ``templates/cursor/hooks/entry.json``."""
|
|
93
|
+
entry = json.loads(read_template("hooks/entry.json"))
|
|
94
|
+
if entry.get("command") != HOOK_COMMAND or "failClosed" in entry or "matcher" in entry:
|
|
95
|
+
msg = "hooks/entry.json must be exactly the cursorfleet-hook command with a timeout"
|
|
96
|
+
raise HooksJsonError(msg)
|
|
97
|
+
return {str(k): v for k, v in entry.items()}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def assert_registrable(events: Iterable[str]) -> tuple[str, ...]:
|
|
101
|
+
"""Raise unless every event is on the v0.1 allowlist and none is forbidden."""
|
|
102
|
+
ordered = tuple(events)
|
|
103
|
+
for name in ordered:
|
|
104
|
+
if name in FORBIDDEN_HOOKS:
|
|
105
|
+
msg = f"refusing to register forbidden hook {name!r} (ADR 0004)"
|
|
106
|
+
raise HooksJsonError(msg)
|
|
107
|
+
if name not in ALLOWED_V01_HOOKS:
|
|
108
|
+
msg = f"refusing to register hook {name!r}: not in ALLOWED_V01_HOOKS"
|
|
109
|
+
raise HooksJsonError(msg)
|
|
110
|
+
return ordered
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_ours(entry: object) -> bool:
|
|
114
|
+
return isinstance(entry, dict) and entry.get("command") == HOOK_COMMAND
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class HooksState:
|
|
119
|
+
"""What a merge did, recorded in the lockfile so uninstall can undo exactly it."""
|
|
120
|
+
|
|
121
|
+
added_version: bool = False
|
|
122
|
+
added_hooks_key: bool = False
|
|
123
|
+
added_events: list[str] = field(default_factory=list)
|
|
124
|
+
entries: dict[str, dict[str, Json]] = field(default_factory=dict)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass
|
|
128
|
+
class MergeResult:
|
|
129
|
+
text: str
|
|
130
|
+
state: HooksState
|
|
131
|
+
style: JsonStyle
|
|
132
|
+
reproducible: bool
|
|
133
|
+
conflicts: list[str]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse(text: str) -> dict[str, Json]:
|
|
137
|
+
try:
|
|
138
|
+
data = json.loads(text)
|
|
139
|
+
except json.JSONDecodeError as exc:
|
|
140
|
+
msg = f"invalid JSON ({exc.msg} at line {exc.lineno})"
|
|
141
|
+
raise HooksJsonError(msg) from exc
|
|
142
|
+
if not isinstance(data, dict):
|
|
143
|
+
msg = "top level must be a JSON object"
|
|
144
|
+
raise HooksJsonError(msg)
|
|
145
|
+
hooks = data.get("hooks")
|
|
146
|
+
if hooks is not None:
|
|
147
|
+
if not isinstance(hooks, dict):
|
|
148
|
+
msg = '"hooks" must be an object'
|
|
149
|
+
raise HooksJsonError(msg)
|
|
150
|
+
for event, entries in hooks.items():
|
|
151
|
+
if not isinstance(entries, list):
|
|
152
|
+
msg = f"hooks.{event} must be a list"
|
|
153
|
+
raise HooksJsonError(msg)
|
|
154
|
+
version = data.get("version")
|
|
155
|
+
if version is not None and version != SUPPORTED_VERSION:
|
|
156
|
+
msg = f'unsupported hooks.json "version": {version!r} (expected {SUPPORTED_VERSION})'
|
|
157
|
+
raise HooksJsonError(msg)
|
|
158
|
+
return data
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def merge( # noqa: PLR0912
|
|
162
|
+
existing: str | None,
|
|
163
|
+
events: Iterable[str],
|
|
164
|
+
previous: Mapping[str, Mapping[str, Json]] | None = None,
|
|
165
|
+
previous_state: HooksState | None = None,
|
|
166
|
+
) -> MergeResult:
|
|
167
|
+
"""Return hooks.json text containing our entries for ``events``.
|
|
168
|
+
|
|
169
|
+
``previous`` are the entries recorded by an earlier install (event -> entry).
|
|
170
|
+
Our entries not in ``events`` are removed if they are unmodified; an entry of
|
|
171
|
+
ours that was modified by the user is a conflict, not silently overwritten.
|
|
172
|
+
"""
|
|
173
|
+
wanted = assert_registrable(events)
|
|
174
|
+
entry = managed_entry()
|
|
175
|
+
previous = previous or {}
|
|
176
|
+
state = HooksState(
|
|
177
|
+
added_version=previous_state.added_version if previous_state else False,
|
|
178
|
+
added_hooks_key=previous_state.added_hooks_key if previous_state else False,
|
|
179
|
+
added_events=list(previous_state.added_events) if previous_state else [],
|
|
180
|
+
)
|
|
181
|
+
conflicts: list[str] = []
|
|
182
|
+
|
|
183
|
+
if existing is None:
|
|
184
|
+
data: dict[str, Json] = {"version": SUPPORTED_VERSION, "hooks": {}}
|
|
185
|
+
style, reproducible = DEFAULT_STYLE, True
|
|
186
|
+
state.added_version = state.added_hooks_key = True
|
|
187
|
+
else:
|
|
188
|
+
data = _parse(existing)
|
|
189
|
+
style, reproducible = detect_style(existing, data)
|
|
190
|
+
if "version" not in data:
|
|
191
|
+
data = {"version": SUPPORTED_VERSION, **data}
|
|
192
|
+
state.added_version = True
|
|
193
|
+
if "hooks" not in data:
|
|
194
|
+
data["hooks"] = {}
|
|
195
|
+
state.added_hooks_key = True
|
|
196
|
+
|
|
197
|
+
hooks: dict[str, list[Json]] = data["hooks"]
|
|
198
|
+
for event in list(hooks):
|
|
199
|
+
ours_idx = [i for i, e in enumerate(hooks[event]) if is_ours(e)]
|
|
200
|
+
if len(ours_idx) > 1:
|
|
201
|
+
conflicts.append(f"hooks.{event} has more than one cursorfleet-hook entry")
|
|
202
|
+
continue
|
|
203
|
+
recorded = previous.get(event)
|
|
204
|
+
if event in wanted:
|
|
205
|
+
if ours_idx:
|
|
206
|
+
current = hooks[event][ours_idx[0]]
|
|
207
|
+
if current != entry:
|
|
208
|
+
if recorded is not None and current == recorded:
|
|
209
|
+
hooks[event][ours_idx[0]] = dict(entry)
|
|
210
|
+
else:
|
|
211
|
+
conflicts.append(
|
|
212
|
+
f"hooks.{event} has a modified cursorfleet-hook entry; "
|
|
213
|
+
"restore it or run `cursorfleet uninstall --force`"
|
|
214
|
+
)
|
|
215
|
+
continue
|
|
216
|
+
# Event not wanted: drop our stale entry if unmodified.
|
|
217
|
+
if ours_idx:
|
|
218
|
+
current = hooks[event][ours_idx[0]]
|
|
219
|
+
if recorded is not None and current == recorded:
|
|
220
|
+
del hooks[event][ours_idx[0]]
|
|
221
|
+
if not hooks[event] and event in state.added_events:
|
|
222
|
+
del hooks[event]
|
|
223
|
+
state.added_events.remove(event)
|
|
224
|
+
else:
|
|
225
|
+
conflicts.append(f"hooks.{event} has a cursorfleet-hook entry we did not record")
|
|
226
|
+
|
|
227
|
+
for event in wanted:
|
|
228
|
+
if event not in hooks:
|
|
229
|
+
hooks[event] = []
|
|
230
|
+
state.added_events.append(event)
|
|
231
|
+
if not any(is_ours(e) for e in hooks[event]):
|
|
232
|
+
hooks[event].append(dict(entry))
|
|
233
|
+
state.entries[event] = dict(entry)
|
|
234
|
+
|
|
235
|
+
return MergeResult(
|
|
236
|
+
text=dumps(data, style),
|
|
237
|
+
state=state,
|
|
238
|
+
style=style,
|
|
239
|
+
reproducible=reproducible,
|
|
240
|
+
conflicts=conflicts,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@dataclass
|
|
245
|
+
class UnmergeResult:
|
|
246
|
+
text: str
|
|
247
|
+
drift: list[str]
|
|
248
|
+
now_empty: bool # nothing but what we created remains
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def unmerge(
|
|
252
|
+
text: str,
|
|
253
|
+
state: HooksState,
|
|
254
|
+
style: JsonStyle,
|
|
255
|
+
*,
|
|
256
|
+
created: bool,
|
|
257
|
+
force: bool = False,
|
|
258
|
+
) -> UnmergeResult:
|
|
259
|
+
"""Remove our entries (and only what we added) from ``text``."""
|
|
260
|
+
data = _parse(text)
|
|
261
|
+
drift: list[str] = []
|
|
262
|
+
hooks = data.get("hooks")
|
|
263
|
+
if isinstance(hooks, dict):
|
|
264
|
+
for event, recorded in state.entries.items():
|
|
265
|
+
entries = hooks.get(event)
|
|
266
|
+
if not isinstance(entries, list):
|
|
267
|
+
continue
|
|
268
|
+
idx = [i for i, e in enumerate(entries) if is_ours(e)]
|
|
269
|
+
for i in reversed(idx):
|
|
270
|
+
if entries[i] != recorded and not force:
|
|
271
|
+
drift.append(f"hooks.{event}: cursorfleet-hook entry was modified")
|
|
272
|
+
continue
|
|
273
|
+
del entries[i]
|
|
274
|
+
if not entries and event in state.added_events:
|
|
275
|
+
del hooks[event]
|
|
276
|
+
if not hooks and state.added_hooks_key:
|
|
277
|
+
del data["hooks"]
|
|
278
|
+
if state.added_version and data.get("version") == SUPPORTED_VERSION:
|
|
279
|
+
del data["version"]
|
|
280
|
+
empty = created and not drift and data == {}
|
|
281
|
+
return UnmergeResult(text=dumps(data, style), drift=drift, now_empty=empty)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def effective_events(text: str) -> dict[str, list[dict[str, Json]]]:
|
|
285
|
+
"""Parse any hooks.json into ``event -> [entries]``; non-object entries are skipped."""
|
|
286
|
+
data = _parse_lenient(text)
|
|
287
|
+
hooks = data.get("hooks")
|
|
288
|
+
result: dict[str, list[dict[str, Json]]] = {}
|
|
289
|
+
if isinstance(hooks, dict):
|
|
290
|
+
for event, entries in hooks.items():
|
|
291
|
+
if isinstance(entries, list):
|
|
292
|
+
result[str(event)] = [e for e in entries if isinstance(e, dict)]
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _parse_lenient(text: str) -> dict[str, Json]:
|
|
297
|
+
try:
|
|
298
|
+
data = json.loads(text)
|
|
299
|
+
except json.JSONDecodeError as exc:
|
|
300
|
+
msg = f"invalid JSON ({exc.msg} at line {exc.lineno})"
|
|
301
|
+
raise HooksJsonError(msg) from exc
|
|
302
|
+
if not isinstance(data, dict):
|
|
303
|
+
msg = "top level must be a JSON object"
|
|
304
|
+
raise HooksJsonError(msg)
|
|
305
|
+
return data
|