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,197 @@
|
|
|
1
|
+
"""``cursorfleet events purge`` and ``events export --sanitized`` (docs/privacy.md)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from pydantic import ValidationError
|
|
12
|
+
|
|
13
|
+
from cursorfleet.adapters.cursor.fsutil import safe_text
|
|
14
|
+
from cursorfleet.config.io import loads_config
|
|
15
|
+
from cursorfleet.events.models import Event
|
|
16
|
+
from cursorfleet.git.runner import NotAGitRepo
|
|
17
|
+
from cursorfleet.state.context import RepoContext, parse_now, resolve_repo
|
|
18
|
+
from cursorfleet.state.indexer import Indexer, IndexerBusy
|
|
19
|
+
from cursorfleet.state.reducer import dedupe_and_sort
|
|
20
|
+
from cursorfleet.state.retention import (
|
|
21
|
+
DEFAULT_MAX_AGE_DAYS,
|
|
22
|
+
DEFAULT_MAX_SESSION_MB,
|
|
23
|
+
PurgePlan,
|
|
24
|
+
apply_plan,
|
|
25
|
+
parse_duration,
|
|
26
|
+
plan_purge,
|
|
27
|
+
)
|
|
28
|
+
from cursorfleet.state.spool_read import read_all
|
|
29
|
+
|
|
30
|
+
RepoOption = Annotated[
|
|
31
|
+
Path, typer.Option("--repo", help="Directory inside the git repo (default: cwd).")
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _retention(ctx: RepoContext) -> tuple[int, int]:
|
|
36
|
+
"""Configured (max_age_days, max_session_mb); defaults if absent or invalid."""
|
|
37
|
+
if ctx.top_level is None:
|
|
38
|
+
return DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SESSION_MB
|
|
39
|
+
path = Path(ctx.top_level) / ".cursorfleet" / "config.toml"
|
|
40
|
+
try:
|
|
41
|
+
config = loads_config(path.read_text(encoding="utf-8"))
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
return DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SESSION_MB
|
|
44
|
+
except (OSError, ValueError) as exc:
|
|
45
|
+
typer.echo(
|
|
46
|
+
f"warning: ignoring unreadable .cursorfleet/config.toml ({exc.__class__.__name__})",
|
|
47
|
+
err=True,
|
|
48
|
+
)
|
|
49
|
+
return DEFAULT_MAX_AGE_DAYS, DEFAULT_MAX_SESSION_MB
|
|
50
|
+
return config.retention.max_age_days, config.retention.max_session_mb
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _describe(plan: PurgePlan) -> str:
|
|
54
|
+
extras = []
|
|
55
|
+
if plan.remove_projection:
|
|
56
|
+
extras.append("projection")
|
|
57
|
+
if plan.rotate_key:
|
|
58
|
+
extras.append("hashing key (rotated)")
|
|
59
|
+
if plan.remove_quarantine:
|
|
60
|
+
extras.append("quarantine")
|
|
61
|
+
tail = f"; also: {', '.join(extras)}" if extras else ""
|
|
62
|
+
return f"{plan.sessions} session(s), {len(plan.files)} file(s), {plan.file_bytes} byte(s){tail}"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def purge( # noqa: PLR0913
|
|
66
|
+
*,
|
|
67
|
+
older_than: Annotated[
|
|
68
|
+
str | None, typer.Option("--older-than", help="Duration such as 7d, 12h, 90m.")
|
|
69
|
+
] = None,
|
|
70
|
+
session: Annotated[str | None, typer.Option("--session", help="One session id.")] = None,
|
|
71
|
+
all_: Annotated[
|
|
72
|
+
bool,
|
|
73
|
+
typer.Option("--all", help="Remove every spool file and the projection; rotate the key."),
|
|
74
|
+
] = False,
|
|
75
|
+
dry_run: Annotated[
|
|
76
|
+
bool, typer.Option("--dry-run", help="Print counts, delete nothing.")
|
|
77
|
+
] = False,
|
|
78
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")] = False,
|
|
79
|
+
repo: RepoOption = Path(),
|
|
80
|
+
now: Annotated[str | None, typer.Option("--now", hidden=True)] = None,
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Delete spool data under the git common dir. Never touches committed config.
|
|
83
|
+
|
|
84
|
+
With no selector the configured retention is applied (default 14 days or 50 MB per
|
|
85
|
+
session). Exit 0 on success (including nothing to purge), 1 on error, 2 on bad usage.
|
|
86
|
+
"""
|
|
87
|
+
if sum([older_than is not None, session is not None, all_]) > 1:
|
|
88
|
+
typer.echo("error: use at most one of --older-than, --session, --all", err=True)
|
|
89
|
+
raise typer.Exit(2)
|
|
90
|
+
try:
|
|
91
|
+
delta = parse_duration(older_than) if older_than is not None else None
|
|
92
|
+
clock = parse_now(now)
|
|
93
|
+
except ValueError as exc:
|
|
94
|
+
typer.echo(f"error: {safe_text(str(exc))}", err=True)
|
|
95
|
+
raise typer.Exit(2) from exc
|
|
96
|
+
try:
|
|
97
|
+
ctx = resolve_repo(repo)
|
|
98
|
+
except NotAGitRepo as exc:
|
|
99
|
+
typer.echo(f"error: {safe_text(str(exc))}", err=True)
|
|
100
|
+
raise typer.Exit(1) from exc
|
|
101
|
+
max_age, max_mb = _retention(ctx)
|
|
102
|
+
plan = plan_purge(
|
|
103
|
+
ctx.paths,
|
|
104
|
+
now=clock,
|
|
105
|
+
older_than=delta,
|
|
106
|
+
session=session,
|
|
107
|
+
everything=all_,
|
|
108
|
+
max_age_days=max_age,
|
|
109
|
+
max_session_mb=max_mb,
|
|
110
|
+
)
|
|
111
|
+
if plan.empty:
|
|
112
|
+
typer.echo("nothing to purge")
|
|
113
|
+
return
|
|
114
|
+
if dry_run:
|
|
115
|
+
typer.echo(f"dry run: would remove {_describe(plan)}")
|
|
116
|
+
return
|
|
117
|
+
_confirm(yes, plan)
|
|
118
|
+
report = apply_plan(ctx.paths, plan)
|
|
119
|
+
if report.needs_reindex and not report.projection_removed:
|
|
120
|
+
try:
|
|
121
|
+
Indexer(ctx.paths).sync(rebuild=True)
|
|
122
|
+
except IndexerBusy:
|
|
123
|
+
typer.echo("note: another indexer is running; run `cursorfleet index --rebuild` later")
|
|
124
|
+
typer.echo(
|
|
125
|
+
f"removed {report.sessions} session(s), {report.files} file(s), {report.bytes} byte(s)"
|
|
126
|
+
+ ("; projection removed" if report.projection_removed else "")
|
|
127
|
+
+ ("; hashing key rotated" if report.key_rotated else "")
|
|
128
|
+
)
|
|
129
|
+
for problem in report.errors:
|
|
130
|
+
typer.echo(f"warning: {problem}", err=True)
|
|
131
|
+
if report.errors:
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _confirm(yes: bool, plan: PurgePlan) -> None:
|
|
136
|
+
if yes:
|
|
137
|
+
return
|
|
138
|
+
if not sys.stdin.isatty():
|
|
139
|
+
typer.echo("error: refusing to delete without --yes when not interactive", err=True)
|
|
140
|
+
raise typer.Exit(2)
|
|
141
|
+
if not typer.confirm(f"Remove {_describe(plan)}?", default=False):
|
|
142
|
+
typer.echo("aborted")
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def export(
|
|
147
|
+
*,
|
|
148
|
+
sanitized: Annotated[
|
|
149
|
+
bool, typer.Option("--sanitized", help="Required: export only re-validated events.")
|
|
150
|
+
] = False,
|
|
151
|
+
session: Annotated[str | None, typer.Option("--session")] = None,
|
|
152
|
+
output: Annotated[Path | None, typer.Option("--output", "-o")] = None,
|
|
153
|
+
repo: RepoOption = Path(),
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Export spool events as JSON Lines after re-validating each one with the Event model.
|
|
156
|
+
|
|
157
|
+
Events are de-duplicated and ordered by (ts, event_id). Invalid or corrupt lines are
|
|
158
|
+
skipped and counted on stderr. Check branch names and paths before sharing.
|
|
159
|
+
"""
|
|
160
|
+
if not sanitized:
|
|
161
|
+
typer.echo("error: only --sanitized export exists; raw export is not offered", err=True)
|
|
162
|
+
raise typer.Exit(2)
|
|
163
|
+
try:
|
|
164
|
+
ctx = resolve_repo(repo)
|
|
165
|
+
except NotAGitRepo as exc:
|
|
166
|
+
typer.echo(f"error: {safe_text(str(exc))}", err=True)
|
|
167
|
+
raise typer.Exit(1) from exc
|
|
168
|
+
spooled, corruption, _files = read_all(ctx.paths, session)
|
|
169
|
+
lines: list[str] = []
|
|
170
|
+
rejected = 0
|
|
171
|
+
for event in dedupe_and_sort(spooled):
|
|
172
|
+
dumped = event.model_dump_json(exclude_none=True)
|
|
173
|
+
try:
|
|
174
|
+
Event.model_validate(json.loads(dumped)) # re-validate the exact bytes we emit
|
|
175
|
+
except (ValidationError, ValueError):
|
|
176
|
+
rejected += 1
|
|
177
|
+
continue
|
|
178
|
+
lines.append(dumped)
|
|
179
|
+
text = "".join(line + "\n" for line in lines)
|
|
180
|
+
if output is None:
|
|
181
|
+
sys.stdout.write(text)
|
|
182
|
+
else:
|
|
183
|
+
with output.open("w", encoding="utf-8", newline="\n") as handle:
|
|
184
|
+
handle.write(text)
|
|
185
|
+
skipped = corruption.total + rejected
|
|
186
|
+
typer.echo(f"exported {len(lines)} event(s); skipped {skipped} corrupt/invalid", err=True)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def register(app: typer.Typer) -> None:
|
|
190
|
+
events = typer.Typer(
|
|
191
|
+
name="events",
|
|
192
|
+
help="Inspect, export and purge the local event spool.",
|
|
193
|
+
no_args_is_help=True,
|
|
194
|
+
)
|
|
195
|
+
events.command("purge")(purge)
|
|
196
|
+
events.command("export")(export)
|
|
197
|
+
app.add_typer(events, name="events")
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""``cursorfleet index [--rebuild]``: run the single-writer indexer once."""
|
|
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.fsutil import safe_text
|
|
12
|
+
from cursorfleet.git.runner import NotAGitRepo
|
|
13
|
+
from cursorfleet.state.context import resolve_repo
|
|
14
|
+
from cursorfleet.state.indexer import Indexer, IndexerBusy
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def register(app: typer.Typer) -> None:
|
|
18
|
+
@app.command("index")
|
|
19
|
+
def index(
|
|
20
|
+
rebuild: Annotated[
|
|
21
|
+
bool,
|
|
22
|
+
typer.Option("--rebuild", help="Delete the projection and rebuild from the spool."),
|
|
23
|
+
] = False,
|
|
24
|
+
json_output: Annotated[bool, typer.Option("--json")] = False,
|
|
25
|
+
repo: Annotated[Path, typer.Option("--repo")] = Path(),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Tail the spool into the SQLite projection once (the projection is rebuildable)."""
|
|
28
|
+
try:
|
|
29
|
+
ctx = resolve_repo(repo)
|
|
30
|
+
except NotAGitRepo as exc:
|
|
31
|
+
typer.echo(f"error: {safe_text(str(exc))}", err=True)
|
|
32
|
+
raise typer.Exit(1) from exc
|
|
33
|
+
try:
|
|
34
|
+
stats = Indexer(ctx.paths).sync(rebuild=rebuild)
|
|
35
|
+
except IndexerBusy as exc:
|
|
36
|
+
typer.echo(f"error: {safe_text(str(exc))}", err=True)
|
|
37
|
+
raise typer.Exit(1) from exc
|
|
38
|
+
summary = {
|
|
39
|
+
"files_scanned": stats.files_scanned,
|
|
40
|
+
"events_read": stats.events_read,
|
|
41
|
+
"events_new": stats.events_new,
|
|
42
|
+
"sessions_updated": stats.sessions_updated,
|
|
43
|
+
"sessions_rebuilt": stats.sessions_rebuilt,
|
|
44
|
+
"sessions_pruned": stats.sessions_pruned,
|
|
45
|
+
"db_recovered": stats.db_recovered,
|
|
46
|
+
"corruption": stats.corruption.to_dict(),
|
|
47
|
+
}
|
|
48
|
+
if json_output:
|
|
49
|
+
typer.echo(json.dumps({"schema": "cursorfleet.index/1", **summary}, indent=2))
|
|
50
|
+
else:
|
|
51
|
+
typer.echo(
|
|
52
|
+
f"indexed {stats.events_new} new event(s) from {stats.files_scanned} file(s); "
|
|
53
|
+
f"{stats.sessions_updated} updated, {stats.sessions_rebuilt} rebuilt, "
|
|
54
|
+
f"{stats.corruption.total} corrupt/skipped"
|
|
55
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""``cursorfleet init --cursor``: install the Cursor kit with a diff and confirmation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from cursorfleet.adapters.cursor import installer
|
|
11
|
+
from cursorfleet.adapters.cursor.fsutil import UnsafePathError, safe_text
|
|
12
|
+
from cursorfleet.adapters.cursor.hooksjson import HOOK_COMMAND
|
|
13
|
+
from cursorfleet.cli.commands._common import fail, show_plan, workspace_root
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(app: typer.Typer) -> None:
|
|
17
|
+
@app.command("init")
|
|
18
|
+
def init(
|
|
19
|
+
cursor: Annotated[
|
|
20
|
+
bool, typer.Option("--cursor", help="Install the kit for Cursor (the only target).")
|
|
21
|
+
] = False,
|
|
22
|
+
dry_run: Annotated[
|
|
23
|
+
bool, typer.Option("--dry-run", help="Print the diff and write nothing.")
|
|
24
|
+
] = False,
|
|
25
|
+
yes: Annotated[
|
|
26
|
+
bool,
|
|
27
|
+
typer.Option("--yes", "-y", help="Skip the prompt (the diff is still printed)."),
|
|
28
|
+
] = False,
|
|
29
|
+
path: Annotated[
|
|
30
|
+
Path,
|
|
31
|
+
typer.Option("--path", help="Repository root (default: current directory)."),
|
|
32
|
+
] = Path(),
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Generate subagents, skills, rules, AGENTS.md blocks and observe-only hooks.
|
|
35
|
+
|
|
36
|
+
Prints an exact diff of every file (including .cursor/hooks.json), then asks for
|
|
37
|
+
confirmation. Existing hooks and AGENTS.md content are preserved. Undo with
|
|
38
|
+
`cursorfleet uninstall`.
|
|
39
|
+
"""
|
|
40
|
+
if not cursor:
|
|
41
|
+
raise fail("error: choose a target, e.g. `cursorfleet init --cursor`.", 2)
|
|
42
|
+
root = workspace_root(path, command="init")
|
|
43
|
+
plan = installer.build_install_plan(root)
|
|
44
|
+
if plan.conflicts:
|
|
45
|
+
typer.echo(
|
|
46
|
+
"Cannot install; resolve these conflicts first (nothing was written):", err=True
|
|
47
|
+
)
|
|
48
|
+
for conflict in plan.conflicts:
|
|
49
|
+
typer.echo(f" - {safe_text(conflict)}", err=True)
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
if plan.noop:
|
|
52
|
+
typer.echo("Already up to date: nothing to do.")
|
|
53
|
+
return
|
|
54
|
+
show_plan(plan)
|
|
55
|
+
if plan.touches_hooks:
|
|
56
|
+
typer.echo(
|
|
57
|
+
f"\nWARNING: this edits .cursor/hooks.json. Cursor runs `{HOOK_COMMAND}` on the "
|
|
58
|
+
"registered events (observe-only: it records sanitized metadata and always "
|
|
59
|
+
"exits 0). Review the hooks.json diff above."
|
|
60
|
+
)
|
|
61
|
+
if dry_run:
|
|
62
|
+
typer.echo("\nDry run: no files were written.")
|
|
63
|
+
return
|
|
64
|
+
if not yes and not typer.confirm("\nApply these changes?", default=False):
|
|
65
|
+
typer.echo("Aborted; nothing was written.")
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
try:
|
|
68
|
+
installer.apply_plan(plan)
|
|
69
|
+
except (OSError, UnsafePathError) as exc:
|
|
70
|
+
raise fail(f"error while writing: {exc}\nRun `cursorfleet doctor` to inspect.") from exc
|
|
71
|
+
typer.echo("\nInstalled. Next: `cursorfleet doctor`. Undo with `cursorfleet uninstall`.")
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""``cursorfleet migrate`` — offline migrations (artifacts, etc.)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from cursorfleet.state.artifact_scan import DEFAULT_WORK_DIR, ArtifactScanner
|
|
11
|
+
from cursorfleet.workflow.artifacts import ARTIFACT_SCHEMA, ARTIFACT_SCHEMA_LEGACY
|
|
12
|
+
|
|
13
|
+
migrate_app = typer.Typer(name="migrate", help="Inspect or migrate local CursorFleet data.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(app: typer.Typer) -> None:
|
|
17
|
+
app.add_typer(migrate_app)
|
|
18
|
+
|
|
19
|
+
@migrate_app.command("artifacts")
|
|
20
|
+
def artifacts(
|
|
21
|
+
path: Annotated[Path, typer.Option("--path", help="Repository root.")] = Path(),
|
|
22
|
+
dry_run: Annotated[
|
|
23
|
+
bool, typer.Option("--dry-run", help="Report only; write nothing.")
|
|
24
|
+
] = True,
|
|
25
|
+
yes: Annotated[
|
|
26
|
+
bool, typer.Option("--yes", help="Apply migrations (not implemented).")
|
|
27
|
+
] = False,
|
|
28
|
+
) -> None:
|
|
29
|
+
"""List legacy YAML artifacts vs TOML 0.1 (dry-run by default)."""
|
|
30
|
+
root = path.resolve()
|
|
31
|
+
work = root / DEFAULT_WORK_DIR
|
|
32
|
+
if not work.is_dir():
|
|
33
|
+
typer.echo("no work directory")
|
|
34
|
+
return
|
|
35
|
+
scan = ArtifactScanner(str(work))
|
|
36
|
+
result = scan.scan([str(root)])
|
|
37
|
+
typer.echo(f"valid artifacts: {len(result.records)} issues: {len(result.issues)}")
|
|
38
|
+
typer.echo(f"target schema: {ARTIFACT_SCHEMA}")
|
|
39
|
+
typer.echo(f"legacy accepted: {ARTIFACT_SCHEMA_LEGACY}")
|
|
40
|
+
if dry_run or not yes:
|
|
41
|
+
typer.echo("dry-run: no files changed (TOML rewrite is manual or future --yes)")
|
|
42
|
+
return
|
|
43
|
+
typer.echo("nothing to apply yet")
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""``cursorfleet replay <session-id> [--json]``: rebuild from the spool, prove determinism."""
|
|
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.fsutil import safe_text
|
|
12
|
+
from cursorfleet.git.runner import NotAGitRepo
|
|
13
|
+
from cursorfleet.state.context import parse_now, resolve_repo
|
|
14
|
+
from cursorfleet.state.reducer import digest, reduce_events, session_view
|
|
15
|
+
from cursorfleet.state.spool_read import read_all
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register(app: typer.Typer) -> None:
|
|
19
|
+
@app.command("replay")
|
|
20
|
+
def replay(
|
|
21
|
+
session_id: Annotated[str, typer.Argument(help="Cursor conversation/session id.")],
|
|
22
|
+
json_output: Annotated[
|
|
23
|
+
bool, typer.Option("--json", help="Machine-readable output.")
|
|
24
|
+
] = False,
|
|
25
|
+
repo: Annotated[
|
|
26
|
+
Path, typer.Option("--repo", help="Directory inside the git repo (default: cwd).")
|
|
27
|
+
] = Path(),
|
|
28
|
+
stale_after_minutes: Annotated[int, typer.Option("--stale-after-minutes", min=1)] = 15,
|
|
29
|
+
now: Annotated[
|
|
30
|
+
str | None, typer.Option("--now", hidden=True, help="ISO-8601 clock for tests.")
|
|
31
|
+
] = None,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Rebuild one session's state from the spool, twice, and check both runs agree.
|
|
34
|
+
|
|
35
|
+
Exit 0 if the two replays are identical, 1 if the session is unknown or the
|
|
36
|
+
replays differ (a bug), 2 on usage errors. Corrupt spool lines are counted, never fatal.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
clock = parse_now(now)
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
typer.echo(f"error: {safe_text(str(exc))}", err=True)
|
|
42
|
+
raise typer.Exit(2) from exc
|
|
43
|
+
try:
|
|
44
|
+
ctx = resolve_repo(repo)
|
|
45
|
+
except NotAGitRepo as exc:
|
|
46
|
+
typer.echo(f"error: {safe_text(str(exc))}", err=True)
|
|
47
|
+
raise typer.Exit(1) from exc
|
|
48
|
+
events_a, corruption, files = read_all(ctx.paths, session_id)
|
|
49
|
+
events_b, _c, _f = read_all(ctx.paths, session_id)
|
|
50
|
+
first = reduce_events(events_a).get(session_id)
|
|
51
|
+
second = reduce_events(reversed(events_b)).get(session_id)
|
|
52
|
+
if first is None or second is None:
|
|
53
|
+
typer.echo(f"error: no events for session {session_id!r} in the spool", err=True)
|
|
54
|
+
raise typer.Exit(1)
|
|
55
|
+
deterministic = digest(first) == digest(second)
|
|
56
|
+
view = session_view(first, clock, stale_after_minutes * 60)
|
|
57
|
+
if json_output:
|
|
58
|
+
payload = {
|
|
59
|
+
"schema": "cursorfleet.replay/1",
|
|
60
|
+
"session_id": session_id,
|
|
61
|
+
"deterministic": deterministic,
|
|
62
|
+
"digest": digest(first),
|
|
63
|
+
"spool_files": files,
|
|
64
|
+
"events": len(events_a),
|
|
65
|
+
"corruption": corruption.to_dict(),
|
|
66
|
+
"state": json.loads(view.model_dump_json()),
|
|
67
|
+
}
|
|
68
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
69
|
+
else:
|
|
70
|
+
typer.echo(
|
|
71
|
+
f"session {session_id}: {len(events_a)} event(s) from {files} file(s); "
|
|
72
|
+
f"lane={view.lane}; corrupt/skipped={corruption.total}"
|
|
73
|
+
)
|
|
74
|
+
typer.echo(f"digest {digest(first)} deterministic={str(deterministic).lower()}")
|
|
75
|
+
if not deterministic:
|
|
76
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""``cursorfleet run`` — observe-only run metadata (runtime dir only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from cursorfleet.git.runner import NotAGitRepo
|
|
13
|
+
from cursorfleet.state.context import resolve_repo
|
|
14
|
+
from cursorfleet.state.run_store import RunRecord, attach_session, list_runs, load_run, save_run
|
|
15
|
+
|
|
16
|
+
run_app = typer.Typer(name="run", help="Local run bookkeeping (does not modify git or hooks).")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def register(app: typer.Typer) -> None:
|
|
20
|
+
app.add_typer(run_app)
|
|
21
|
+
|
|
22
|
+
@run_app.command("start")
|
|
23
|
+
def start(
|
|
24
|
+
task: Annotated[
|
|
25
|
+
str, typer.Option("--task", help="Task slug under .cursorfleet/work/<task>/")
|
|
26
|
+
],
|
|
27
|
+
path: Annotated[
|
|
28
|
+
Path, typer.Option("--path", help="Repository root or inside it.")
|
|
29
|
+
] = Path(),
|
|
30
|
+
run_id: Annotated[
|
|
31
|
+
str | None, typer.Option("--run-id", help="Optional stable id; default: random.")
|
|
32
|
+
] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
try:
|
|
35
|
+
ctx = resolve_repo(path.resolve())
|
|
36
|
+
except NotAGitRepo:
|
|
37
|
+
typer.echo("error: not a git repository", err=True)
|
|
38
|
+
raise typer.Exit(2) from None
|
|
39
|
+
rid = run_id or f"run-{uuid.uuid4().hex[:12]}"
|
|
40
|
+
record = RunRecord(
|
|
41
|
+
run_id=rid,
|
|
42
|
+
task_slug=task,
|
|
43
|
+
created_at=datetime.now(tz=UTC).isoformat(),
|
|
44
|
+
)
|
|
45
|
+
save_run(ctx.paths.root, record)
|
|
46
|
+
typer.echo(rid)
|
|
47
|
+
|
|
48
|
+
@run_app.command("list")
|
|
49
|
+
def list_cmd(
|
|
50
|
+
path: Annotated[Path, typer.Option("--path")] = Path(),
|
|
51
|
+
) -> None:
|
|
52
|
+
try:
|
|
53
|
+
ctx = resolve_repo(path.resolve())
|
|
54
|
+
except NotAGitRepo:
|
|
55
|
+
typer.echo("error: not a git repository", err=True)
|
|
56
|
+
raise typer.Exit(2) from None
|
|
57
|
+
rows = list_runs(ctx.paths.root)
|
|
58
|
+
if not rows:
|
|
59
|
+
typer.echo("no runs")
|
|
60
|
+
return
|
|
61
|
+
for row in rows:
|
|
62
|
+
flag = "archived" if row.archived else "active"
|
|
63
|
+
typer.echo(f"{row.run_id}\t{row.task_slug}\t{flag}\tsessions={len(row.session_ids)}")
|
|
64
|
+
|
|
65
|
+
@run_app.command("attach")
|
|
66
|
+
def attach(
|
|
67
|
+
run_id: Annotated[str, typer.Argument(help="Run id from run start")],
|
|
68
|
+
session: Annotated[str, typer.Option("--session", help="Cursor session_id to attach.")],
|
|
69
|
+
path: Annotated[Path, typer.Option("--path")] = Path(),
|
|
70
|
+
) -> None:
|
|
71
|
+
try:
|
|
72
|
+
ctx = resolve_repo(path.resolve())
|
|
73
|
+
except NotAGitRepo:
|
|
74
|
+
typer.echo("error: not a git repository", err=True)
|
|
75
|
+
raise typer.Exit(2) from None
|
|
76
|
+
if not attach_session(ctx.paths.root, run_id, session):
|
|
77
|
+
typer.echo("attach failed (unknown or archived run)", err=True)
|
|
78
|
+
raise typer.Exit(1)
|
|
79
|
+
typer.echo("attached")
|
|
80
|
+
|
|
81
|
+
@run_app.command("archive")
|
|
82
|
+
def archive(
|
|
83
|
+
run_id: Annotated[str, typer.Argument()],
|
|
84
|
+
path: Annotated[Path, typer.Option("--path")] = Path(),
|
|
85
|
+
) -> None:
|
|
86
|
+
try:
|
|
87
|
+
ctx = resolve_repo(path.resolve())
|
|
88
|
+
except NotAGitRepo:
|
|
89
|
+
typer.echo("error: not a git repository", err=True)
|
|
90
|
+
raise typer.Exit(2) from None
|
|
91
|
+
record = load_run(ctx.paths.root, run_id)
|
|
92
|
+
if record is None:
|
|
93
|
+
typer.echo("run not found", err=True)
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
record.archived = True
|
|
96
|
+
save_run(ctx.paths.root, record)
|
|
97
|
+
typer.echo("archived")
|