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