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,428 @@
|
|
|
1
|
+
"""``cursorfleet-hook``: the Cursor hook hot-path entrypoint. Stdlib only, fails open.
|
|
2
|
+
|
|
3
|
+
Contract (ADR 0004, docs/architecture.md):
|
|
4
|
+
|
|
5
|
+
- Reads one hook JSON payload from stdin; the hook name comes from ``hook_event_name``.
|
|
6
|
+
- ALWAYS exits 0 and ALWAYS prints the fail-open reply for that hook
|
|
7
|
+
(``{"permission":"allow"}`` for permission hooks, else ``{}``), whatever goes wrong.
|
|
8
|
+
- Imports only the stdlib and small stdlib-only CursorFleet modules (no pydantic,
|
|
9
|
+
typer, textual, sqlite3 or any network module). A test enforces this.
|
|
10
|
+
- Appends sanitized events to ``<git-common-dir>/cursorfleet/spool/...``. It never opens
|
|
11
|
+
SQLite. It records nothing unless the nearest Git root already has the install marker
|
|
12
|
+
``.cursorfleet/config.toml`` (ADR 0002). Missing marker, an inner uninitialized
|
|
13
|
+
repository, an external symlink or an ambiguous multi-root workspace fail open.
|
|
14
|
+
|
|
15
|
+
PROVISIONAL: if the hook name cannot be determined (garbage stdin and no argv hint) the
|
|
16
|
+
reply is ``{"permission":"allow"}``, because Cursor treats an invalid reply from a
|
|
17
|
+
permission hook as a block while an unknown extra field on a non-permission reply is
|
|
18
|
+
assumed harmless (unverified).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
from cursorfleet import __version__
|
|
29
|
+
from cursorfleet.adapters.cursor.hook_policy import (
|
|
30
|
+
PERMISSION_HOOKS,
|
|
31
|
+
fail_open_response,
|
|
32
|
+
is_registrable,
|
|
33
|
+
)
|
|
34
|
+
from cursorfleet.events.ids import worktree_id_for
|
|
35
|
+
from cursorfleet.state.runtime import (
|
|
36
|
+
GitLocation,
|
|
37
|
+
RuntimePaths,
|
|
38
|
+
ensure_runtime_root,
|
|
39
|
+
has_install_marker,
|
|
40
|
+
is_ambiguous_workspace_roots,
|
|
41
|
+
is_external_symlink_anchor,
|
|
42
|
+
resolve_inherited_location,
|
|
43
|
+
runtime_paths,
|
|
44
|
+
safe_realpath,
|
|
45
|
+
untrusted_reason,
|
|
46
|
+
write_private_file,
|
|
47
|
+
)
|
|
48
|
+
from cursorfleet.state.spool import DEFAULT_MAX_SESSION_BYTES, append_event
|
|
49
|
+
|
|
50
|
+
TYPE_CHECKING = False # avoids importing ``typing`` (~4 ms) on the hook hot path
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
53
|
+
from typing import BinaryIO, TextIO
|
|
54
|
+
|
|
55
|
+
MAX_STDIN_BYTES = 8 * 1024 * 1024
|
|
56
|
+
_NO_COMMAND_HOOKS = frozenset({"sessionStart", "sessionEnd", "stop", "afterFileEdit", "preCompact"})
|
|
57
|
+
_MAX_CONFIG_BYTES = 256 * 1024
|
|
58
|
+
_SAFE_HINT_CHARS = frozenset("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
59
|
+
_ALLOW = '{"permission":"allow"}'
|
|
60
|
+
# Cursor permission hooks we never register but would still answer correctly if someone
|
|
61
|
+
# else (or a mistake) wires them to this command: an invalid reply there blocks the action.
|
|
62
|
+
_EXTRA_PERMISSION_HOOKS = frozenset({"beforeMCPExecution", "beforeReadFile", "beforeTabFileRead"})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def reply_for(hook: str | None) -> str:
|
|
66
|
+
"""The fail-open reply for ``hook``; allow-shaped when the hook is unknown."""
|
|
67
|
+
if hook is None or hook in _EXTRA_PERMISSION_HOOKS or hook in PERMISSION_HOOKS:
|
|
68
|
+
return _ALLOW
|
|
69
|
+
return fail_open_response(hook)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------- inputs
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_stdin_bytes(stream: BinaryIO | None, limit: int = MAX_STDIN_BYTES) -> bytes | None:
|
|
76
|
+
"""Read (and drain) stdin; ``None`` if over ``limit`` or unavailable. Never raises."""
|
|
77
|
+
if stream is None:
|
|
78
|
+
return None
|
|
79
|
+
try:
|
|
80
|
+
if stream.isatty(): # never block on an interactive terminal
|
|
81
|
+
return None
|
|
82
|
+
chunks: list[bytes] = []
|
|
83
|
+
total = 0
|
|
84
|
+
while True:
|
|
85
|
+
chunk = stream.read(65536)
|
|
86
|
+
if not chunk:
|
|
87
|
+
break
|
|
88
|
+
total += len(chunk)
|
|
89
|
+
if total <= limit:
|
|
90
|
+
chunks.append(chunk)
|
|
91
|
+
return b"".join(chunks) if total <= limit else None
|
|
92
|
+
except (OSError, ValueError):
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def parse_payload(data: bytes | None) -> dict[str, object] | None:
|
|
97
|
+
if not data:
|
|
98
|
+
return None
|
|
99
|
+
try:
|
|
100
|
+
parsed = json.loads(data)
|
|
101
|
+
except (ValueError, RecursionError):
|
|
102
|
+
return None
|
|
103
|
+
return parsed if isinstance(parsed, dict) else None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _hint(argv: Sequence[str]) -> str | None:
|
|
107
|
+
if len(argv) > 1 and argv[1] and all(c in _SAFE_HINT_CHARS for c in argv[1]):
|
|
108
|
+
return argv[1]
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------- git context
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _read_text(path: str, limit: int = 4096) -> str | None:
|
|
116
|
+
try:
|
|
117
|
+
with open(path, "rb") as handle:
|
|
118
|
+
return handle.read(limit).decode("utf-8", "replace")
|
|
119
|
+
except OSError:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _is_commit(value: str) -> bool:
|
|
124
|
+
return 7 <= len(value) <= 64 and all(c in "0123456789abcdef" for c in value)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def read_head(location: GitLocation) -> tuple[str | None, str | None]:
|
|
128
|
+
"""Return ``(branch, commit)`` from ``HEAD`` by reading files; either may be ``None``."""
|
|
129
|
+
head = _read_text(os.path.join(location.git_dir, "HEAD"))
|
|
130
|
+
if head is None:
|
|
131
|
+
return None, None
|
|
132
|
+
line = head.strip().splitlines()[0] if head.strip() else ""
|
|
133
|
+
if not line.startswith("ref:"):
|
|
134
|
+
return None, line if _is_commit(line) else None
|
|
135
|
+
ref = line[4:].strip()
|
|
136
|
+
if not ref.startswith("refs/") or ".." in ref or "\\" in ref or ":" in ref:
|
|
137
|
+
return None, None # ":" would let a Windows drive component escape the git dir
|
|
138
|
+
branch = ref[len("refs/heads/") :] if ref.startswith("refs/heads/") else None
|
|
139
|
+
if branch is not None and not (1 <= len(branch) <= 255 and branch.isprintable()):
|
|
140
|
+
branch = None
|
|
141
|
+
text = _read_text(os.path.join(location.common_dir, *ref.split("/")))
|
|
142
|
+
if text is not None:
|
|
143
|
+
sha = text.strip().splitlines()[0] if text.strip() else ""
|
|
144
|
+
if _is_commit(sha):
|
|
145
|
+
return branch, sha
|
|
146
|
+
packed = _read_text(os.path.join(location.common_dir, "packed-refs"), 1024 * 1024)
|
|
147
|
+
if packed is not None:
|
|
148
|
+
for entry in packed.splitlines():
|
|
149
|
+
sha, _, name = entry.partition(" ")
|
|
150
|
+
if name == ref and _is_commit(sha):
|
|
151
|
+
return branch, sha
|
|
152
|
+
return branch, None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------- local config and key
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class Settings:
|
|
159
|
+
"""Hot-path view of ``.cursorfleet/config.toml`` (only privacy and retention knobs)."""
|
|
160
|
+
|
|
161
|
+
__slots__ = ("display_max", "hash_commands", "max_session_bytes", "store_display")
|
|
162
|
+
|
|
163
|
+
def __init__(self) -> None:
|
|
164
|
+
self.store_display = False
|
|
165
|
+
self.display_max = 200
|
|
166
|
+
self.hash_commands = True
|
|
167
|
+
self.max_session_bytes = DEFAULT_MAX_SESSION_BYTES
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def load_settings(top_level: str, environ: Mapping[str, str]) -> Settings:
|
|
171
|
+
"""Read privacy/retention settings; fail toward LESS storage on any parse problem."""
|
|
172
|
+
settings = Settings()
|
|
173
|
+
override = environ.get("CURSORFLEET_MAX_SESSION_MB")
|
|
174
|
+
if override and override.isdigit() and 1 <= int(override) <= 1024:
|
|
175
|
+
settings.max_session_bytes = int(override) * 1024 * 1024
|
|
176
|
+
path = os.path.join(top_level, ".cursorfleet", "config.toml")
|
|
177
|
+
try:
|
|
178
|
+
with open(path, "rb") as handle:
|
|
179
|
+
data = handle.read(_MAX_CONFIG_BYTES + 1)
|
|
180
|
+
except OSError:
|
|
181
|
+
return settings
|
|
182
|
+
if len(data) > _MAX_CONFIG_BYTES:
|
|
183
|
+
settings.store_display = False
|
|
184
|
+
return settings
|
|
185
|
+
if b"privacy" not in data and b"retention" not in data:
|
|
186
|
+
return settings
|
|
187
|
+
try:
|
|
188
|
+
import tomllib # lazy: only when the user customized these sections
|
|
189
|
+
|
|
190
|
+
parsed = tomllib.loads(data.decode("utf-8"))
|
|
191
|
+
privacy = parsed.get("privacy", {})
|
|
192
|
+
retention = parsed.get("retention", {})
|
|
193
|
+
if isinstance(privacy, dict):
|
|
194
|
+
if privacy.get("store_command_display") is True:
|
|
195
|
+
settings.store_display = True
|
|
196
|
+
elif privacy.get("store_command_display") is False:
|
|
197
|
+
settings.store_display = False
|
|
198
|
+
if privacy.get("hash_commands") is False:
|
|
199
|
+
settings.hash_commands = False
|
|
200
|
+
max_chars = privacy.get("command_display_max_chars")
|
|
201
|
+
if isinstance(max_chars, int) and not isinstance(max_chars, bool):
|
|
202
|
+
settings.display_max = max(20, min(max_chars, 200))
|
|
203
|
+
if isinstance(retention, dict) and "CURSORFLEET_MAX_SESSION_MB" not in environ:
|
|
204
|
+
mb = retention.get("max_session_mb")
|
|
205
|
+
if isinstance(mb, int) and not isinstance(mb, bool) and 1 <= mb <= 1024:
|
|
206
|
+
settings.max_session_bytes = mb * 1024 * 1024
|
|
207
|
+
except (ValueError, UnicodeDecodeError, OSError):
|
|
208
|
+
settings.store_display = False
|
|
209
|
+
return settings
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def load_hmac_key(paths: RuntimePaths) -> bytes | None:
|
|
213
|
+
"""Return the per-install HMAC key, creating it (0600) on first use."""
|
|
214
|
+
if untrusted_reason(paths.root) is not None:
|
|
215
|
+
return None
|
|
216
|
+
try:
|
|
217
|
+
with open(paths.hmac_key, "rb") as handle:
|
|
218
|
+
key = handle.read(256)
|
|
219
|
+
if len(key) >= 16:
|
|
220
|
+
return key
|
|
221
|
+
except OSError:
|
|
222
|
+
pass
|
|
223
|
+
try:
|
|
224
|
+
ensure_runtime_root(paths)
|
|
225
|
+
write_private_file(paths.hmac_key, os.urandom(32), exclusive=True)
|
|
226
|
+
with open(paths.hmac_key, "rb") as handle:
|
|
227
|
+
key = handle.read(256)
|
|
228
|
+
return key if len(key) >= 16 else None
|
|
229
|
+
except OSError:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------- core
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _tool_cwd(payload: dict[str, object]) -> str | None:
|
|
237
|
+
"""Cursor tool cwd when the payload names a path that still exists (including a symlink)."""
|
|
238
|
+
cwd = payload.get("cwd")
|
|
239
|
+
if not isinstance(cwd, str) or not cwd:
|
|
240
|
+
return None
|
|
241
|
+
try:
|
|
242
|
+
return cwd if os.path.lexists(cwd) else None
|
|
243
|
+
except OSError:
|
|
244
|
+
return None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _anchor(payload: dict[str, object], environ: Mapping[str, str]) -> str | None:
|
|
248
|
+
"""Single resolution start: tool cwd when available, otherwise ``CURSOR_PROJECT_DIR``.
|
|
249
|
+
|
|
250
|
+
Tool cwd is ``payload.cwd`` when that path lexists, else the hook process cwd.
|
|
251
|
+
``workspace_roots`` is never a start (ADR 0002 runtime inheritance).
|
|
252
|
+
"""
|
|
253
|
+
cwd = _tool_cwd(payload)
|
|
254
|
+
if cwd is not None:
|
|
255
|
+
return cwd
|
|
256
|
+
try:
|
|
257
|
+
return os.getcwd()
|
|
258
|
+
except OSError:
|
|
259
|
+
pass
|
|
260
|
+
project = environ.get("CURSOR_PROJECT_DIR")
|
|
261
|
+
return project if project else None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _resolve_runtime_location(
|
|
265
|
+
payload: dict[str, object], environ: Mapping[str, str]
|
|
266
|
+
) -> GitLocation | None:
|
|
267
|
+
"""Inherited Git root for this hook, or ``None`` (record nothing, fail open).
|
|
268
|
+
|
|
269
|
+
Validates the install marker before the caller may create a runtime directory.
|
|
270
|
+
Never continues past an inner ``.git`` / gitfile / submodule boundary.
|
|
271
|
+
"""
|
|
272
|
+
if is_ambiguous_workspace_roots(payload.get("workspace_roots")):
|
|
273
|
+
return None
|
|
274
|
+
anchor = _anchor(payload, environ)
|
|
275
|
+
if anchor is None or is_external_symlink_anchor(anchor):
|
|
276
|
+
return None
|
|
277
|
+
try:
|
|
278
|
+
real = safe_realpath(anchor)
|
|
279
|
+
except (OSError, ValueError):
|
|
280
|
+
return None
|
|
281
|
+
location = resolve_inherited_location(real)
|
|
282
|
+
if location is None or not has_install_marker(location.top_level):
|
|
283
|
+
return None
|
|
284
|
+
return location
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _dump(event: dict[str, object]) -> str:
|
|
288
|
+
return json.dumps(event, separators=(",", ":"), ensure_ascii=True)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _fit(event: dict[str, object], limit: int = 7800) -> str | None:
|
|
292
|
+
"""Serialize ``event`` under the line cap by dropping optional detail; else ``None``."""
|
|
293
|
+
text = _dump(event)
|
|
294
|
+
if len(text) <= limit:
|
|
295
|
+
return text
|
|
296
|
+
command = event.get("command")
|
|
297
|
+
if isinstance(command, dict):
|
|
298
|
+
command.pop("display", None)
|
|
299
|
+
command.pop("display_truncated", None)
|
|
300
|
+
text = _dump(event)
|
|
301
|
+
paths = event.get("paths")
|
|
302
|
+
while len(text) > limit and isinstance(paths, list) and len(paths) > 1:
|
|
303
|
+
del paths[len(paths) // 2 :]
|
|
304
|
+
text = _dump(event)
|
|
305
|
+
return text if len(text) <= limit else None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def record(
|
|
309
|
+
hook: str,
|
|
310
|
+
payload: dict[str, object],
|
|
311
|
+
*,
|
|
312
|
+
environ: Mapping[str, str],
|
|
313
|
+
now_ns: int,
|
|
314
|
+
entropy: Callable[[], bytes] | None = None,
|
|
315
|
+
) -> int:
|
|
316
|
+
"""Normalize and append events for one hook payload. Returns the number written."""
|
|
317
|
+
from cursorfleet.adapters.cursor import hook_normalize
|
|
318
|
+
from cursorfleet.adapters.cursor.hook_sanitize import PathResolver
|
|
319
|
+
|
|
320
|
+
location = _resolve_runtime_location(payload, environ)
|
|
321
|
+
if location is None:
|
|
322
|
+
return 0 # missing marker, rejected root, or not a git repo: record nothing
|
|
323
|
+
paths = runtime_paths(location.common_dir)
|
|
324
|
+
settings = load_settings(location.top_level, environ)
|
|
325
|
+
branch = commit = None
|
|
326
|
+
if hook in {"sessionStart", "subagentStart"}:
|
|
327
|
+
branch, commit = read_head(location)
|
|
328
|
+
uses_commands = hook not in _NO_COMMAND_HOOKS
|
|
329
|
+
ctx = hook_normalize.HookContext(
|
|
330
|
+
now_ns=now_ns,
|
|
331
|
+
producer_version=__version__,
|
|
332
|
+
resolver=PathResolver([location.top_level]),
|
|
333
|
+
worktree_id=worktree_id_for(location.top_level),
|
|
334
|
+
branch=branch,
|
|
335
|
+
commit=commit,
|
|
336
|
+
key=load_hmac_key(paths) if uses_commands and settings.hash_commands else None,
|
|
337
|
+
store_display=settings.store_display,
|
|
338
|
+
display_max=settings.display_max,
|
|
339
|
+
hash_commands=settings.hash_commands,
|
|
340
|
+
entropy=entropy,
|
|
341
|
+
)
|
|
342
|
+
events = hook_normalize.normalize(hook, payload, ctx)
|
|
343
|
+
if not events:
|
|
344
|
+
return 0
|
|
345
|
+
writer = hook_normalize.writer_of(hook, payload)
|
|
346
|
+
written = 0
|
|
347
|
+
attached_session: str | None = None
|
|
348
|
+
for event in events:
|
|
349
|
+
text = _fit(event)
|
|
350
|
+
session_id = event.get("session_id")
|
|
351
|
+
if text is None or not isinstance(session_id, str):
|
|
352
|
+
continue
|
|
353
|
+
if append_event(
|
|
354
|
+
paths,
|
|
355
|
+
session_id,
|
|
356
|
+
writer,
|
|
357
|
+
text,
|
|
358
|
+
now_ms=now_ns // 1_000_000,
|
|
359
|
+
max_session_bytes=settings.max_session_bytes,
|
|
360
|
+
):
|
|
361
|
+
written += 1
|
|
362
|
+
attached_session = session_id
|
|
363
|
+
if written and attached_session:
|
|
364
|
+
active = environ.get("CURSORFLEET_ACTIVE_RUN", "").strip()
|
|
365
|
+
if active:
|
|
366
|
+
try:
|
|
367
|
+
from cursorfleet.state.run_store import attach_session
|
|
368
|
+
|
|
369
|
+
attach_session(paths.root, active, attached_session)
|
|
370
|
+
except OSError:
|
|
371
|
+
pass # fail-open: run attach must never block hooks
|
|
372
|
+
return written
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def run(
|
|
376
|
+
argv: Sequence[str],
|
|
377
|
+
stdin: BinaryIO | None,
|
|
378
|
+
*,
|
|
379
|
+
environ: Mapping[str, str],
|
|
380
|
+
now_ns: int,
|
|
381
|
+
entropy: Callable[[], bytes] | None = None,
|
|
382
|
+
) -> str:
|
|
383
|
+
"""Process one hook invocation and return the reply text. Never raises."""
|
|
384
|
+
hook = _hint(argv)
|
|
385
|
+
try:
|
|
386
|
+
payload = parse_payload(read_stdin_bytes(stdin))
|
|
387
|
+
if payload is not None:
|
|
388
|
+
name = payload.get("hook_event_name")
|
|
389
|
+
if isinstance(name, str):
|
|
390
|
+
hook = name
|
|
391
|
+
if payload is not None and hook is not None and is_registrable(hook):
|
|
392
|
+
record(hook, payload, environ=environ, now_ns=now_ns, entropy=entropy)
|
|
393
|
+
except Exception: # noqa: S110 - the hot path must fail open on anything
|
|
394
|
+
pass
|
|
395
|
+
return reply_for(hook)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def main(
|
|
399
|
+
argv: Sequence[str] | None = None,
|
|
400
|
+
*,
|
|
401
|
+
stdin: BinaryIO | None = None,
|
|
402
|
+
stdout: TextIO | None = None,
|
|
403
|
+
environ: Mapping[str, str] | None = None,
|
|
404
|
+
clock_ns: Callable[[], int] | None = None,
|
|
405
|
+
entropy: Callable[[], bytes] | None = None,
|
|
406
|
+
) -> int:
|
|
407
|
+
"""Console-script entry point. Always returns 0."""
|
|
408
|
+
try:
|
|
409
|
+
reply = run(
|
|
410
|
+
sys.argv if argv is None else argv,
|
|
411
|
+
stdin if stdin is not None else getattr(sys.stdin, "buffer", None),
|
|
412
|
+
environ=os.environ if environ is None else environ,
|
|
413
|
+
now_ns=(clock_ns or time.time_ns)(),
|
|
414
|
+
entropy=entropy,
|
|
415
|
+
)
|
|
416
|
+
except BaseException: # last resort; even KeyboardInterrupt exits open
|
|
417
|
+
reply = _ALLOW
|
|
418
|
+
try:
|
|
419
|
+
out = sys.stdout if stdout is None else stdout
|
|
420
|
+
out.write(reply + "\n")
|
|
421
|
+
out.flush()
|
|
422
|
+
except Exception: # noqa: S110
|
|
423
|
+
pass
|
|
424
|
+
return 0
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
sys.exit(main())
|