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,210 @@
|
|
|
1
|
+
"""``cursorfleet status [--json]``: fleet snapshot from the projection plus read-only git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from cursorfleet.adapters.cursor.kit_probe import probe_hooks_kit
|
|
13
|
+
from cursorfleet.git.collector import DEFAULT_STALE_AFTER_HOURS, GitSnapshot, collect
|
|
14
|
+
from cursorfleet.git.runner import NotAGitRepo
|
|
15
|
+
from cursorfleet.state.artifact_scan import DEFAULT_WORK_DIR, ArtifactScanner
|
|
16
|
+
from cursorfleet.state.context import RepoContext, parse_now, resolve_repo
|
|
17
|
+
from cursorfleet.state.indexer import Indexer, replay_session_state
|
|
18
|
+
from cursorfleet.state.models import FleetView, SessionAcc
|
|
19
|
+
from cursorfleet.state.reducer import snapshot
|
|
20
|
+
from cursorfleet.state.run_builder import build_active_run, pick_primary_task
|
|
21
|
+
from cursorfleet.state.run_store import list_runs
|
|
22
|
+
from cursorfleet.state.spool_read import Corruption, list_spool_files
|
|
23
|
+
from cursorfleet.state.status_doc import (
|
|
24
|
+
STATUS_SCHEMA,
|
|
25
|
+
RunDoc,
|
|
26
|
+
StatusDoc,
|
|
27
|
+
build_status,
|
|
28
|
+
run_docs_from_snapshots,
|
|
29
|
+
worktree_activity,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe(text: object) -> str:
|
|
34
|
+
"""Make untrusted strings safe for a terminal (no control or bidi characters)."""
|
|
35
|
+
return "".join(c if c.isprintable() else "?" for c in str(text))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load(ctx: RepoContext) -> tuple[dict[str, SessionAcc], Corruption, int, str]:
|
|
39
|
+
"""Return (sessions, corruption, spool file count, source)."""
|
|
40
|
+
files = len(list_spool_files(ctx.paths))
|
|
41
|
+
if files == 0:
|
|
42
|
+
return {}, Corruption(), 0, "none"
|
|
43
|
+
indexer = Indexer(ctx.paths)
|
|
44
|
+
indexer.try_sync() # another indexer may be running; then we just read its projection
|
|
45
|
+
sessions = indexer.load_sessions()
|
|
46
|
+
if sessions:
|
|
47
|
+
return sessions, indexer.corruption(), files, "projection"
|
|
48
|
+
replayed, corruption, _n = replay_session_state(ctx.paths)
|
|
49
|
+
return replayed, corruption, files, "replay"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build(ctx: RepoContext, *, now: datetime, stale_after_s: int, with_git: bool) -> StatusDoc:
|
|
53
|
+
sessions, corruption, files, source = _load(ctx)
|
|
54
|
+
fleet = snapshot(sessions, now=now, stale_after_s=stale_after_s)
|
|
55
|
+
git: GitSnapshot | None = None
|
|
56
|
+
if with_git:
|
|
57
|
+
git = collect(
|
|
58
|
+
ctx.top_level or ctx.common_dir,
|
|
59
|
+
now=now,
|
|
60
|
+
stale_after_hours=DEFAULT_STALE_AFTER_HOURS,
|
|
61
|
+
activity=worktree_activity(sessions),
|
|
62
|
+
)
|
|
63
|
+
repo_root = ctx.top_level or ctx.common_dir
|
|
64
|
+
run_docs = _status_runs(
|
|
65
|
+
ctx=ctx,
|
|
66
|
+
sessions=sessions,
|
|
67
|
+
fleet=fleet,
|
|
68
|
+
now=now,
|
|
69
|
+
stale_after_s=stale_after_s,
|
|
70
|
+
repo_root=repo_root,
|
|
71
|
+
)
|
|
72
|
+
return build_status(
|
|
73
|
+
now=now,
|
|
74
|
+
fleet=fleet,
|
|
75
|
+
git=git,
|
|
76
|
+
common_dir=ctx.common_dir,
|
|
77
|
+
runtime_dir=ctx.paths.root,
|
|
78
|
+
corruption=corruption,
|
|
79
|
+
spool_files=files,
|
|
80
|
+
source=source,
|
|
81
|
+
hooks_kit=probe_hooks_kit(repo_root).state,
|
|
82
|
+
runs=run_docs or None,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _status_runs( # noqa: PLR0913
|
|
87
|
+
*,
|
|
88
|
+
ctx: RepoContext,
|
|
89
|
+
sessions: dict[str, SessionAcc],
|
|
90
|
+
fleet: FleetView,
|
|
91
|
+
now: datetime,
|
|
92
|
+
stale_after_s: int,
|
|
93
|
+
repo_root: str,
|
|
94
|
+
) -> list[RunDoc]:
|
|
95
|
+
session_views = {s.session_id: s for s in fleet.sessions}
|
|
96
|
+
scan = ArtifactScanner(DEFAULT_WORK_DIR).scan([repo_root])
|
|
97
|
+
records = scan.records
|
|
98
|
+
snapshots = []
|
|
99
|
+
for meta in sorted(
|
|
100
|
+
(r for r in list_runs(ctx.paths.root) if not r.archived),
|
|
101
|
+
key=lambda r: r.created_at,
|
|
102
|
+
reverse=True,
|
|
103
|
+
):
|
|
104
|
+
snapshots.append(
|
|
105
|
+
build_active_run(
|
|
106
|
+
task_slug=meta.task_slug,
|
|
107
|
+
run_id=meta.run_id,
|
|
108
|
+
sessions=sessions,
|
|
109
|
+
session_views=session_views,
|
|
110
|
+
records=records,
|
|
111
|
+
linked_session_ids=frozenset(meta.session_ids),
|
|
112
|
+
now=now,
|
|
113
|
+
stale_after_s=stale_after_s,
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
if not snapshots:
|
|
117
|
+
task = pick_primary_task(records, now=now)
|
|
118
|
+
if task is None and not session_views:
|
|
119
|
+
return []
|
|
120
|
+
slug = task or "unscoped"
|
|
121
|
+
snapshots.append(
|
|
122
|
+
build_active_run(
|
|
123
|
+
task_slug=slug,
|
|
124
|
+
run_id=f"inferred:{slug}",
|
|
125
|
+
sessions=sessions,
|
|
126
|
+
session_views=session_views,
|
|
127
|
+
records=records,
|
|
128
|
+
linked_session_ids=frozenset(),
|
|
129
|
+
now=now,
|
|
130
|
+
stale_after_s=stale_after_s,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
docs: list[RunDoc] = run_docs_from_snapshots(snapshots)
|
|
134
|
+
return docs
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _render_text(doc: StatusDoc) -> str:
|
|
138
|
+
lines = [
|
|
139
|
+
f"CursorFleet status ({doc.generated_at.isoformat()})",
|
|
140
|
+
f"telemetry: {doc.telemetry.state} | sessions {doc.telemetry.sessions} | "
|
|
141
|
+
f"events {doc.telemetry.events} | token budget: {doc.limits.token_budget}",
|
|
142
|
+
]
|
|
143
|
+
lines.extend(f"note: {_safe(n)}" for n in doc.telemetry.notes)
|
|
144
|
+
lines.append("")
|
|
145
|
+
lines.append("SESSIONS")
|
|
146
|
+
if not doc.sessions:
|
|
147
|
+
awaiting = any("hooks installed" in n for n in doc.telemetry.notes)
|
|
148
|
+
hint = "await Cursor session" if awaiting else "no hook telemetry"
|
|
149
|
+
lines.append(f" (none: {hint})")
|
|
150
|
+
for s in doc.sessions:
|
|
151
|
+
lines.append(
|
|
152
|
+
f" {_safe(s.session_id)} lane={s.lane} tools={s.tool_call_count} "
|
|
153
|
+
f"compactions={s.compactions} elapsed={s.elapsed_s:.0f}s events={s.event_count}"
|
|
154
|
+
)
|
|
155
|
+
for a in s.agents:
|
|
156
|
+
lines.append(
|
|
157
|
+
f" - {_safe(a.key)} lane={a.lane} ({a.lane_basis}) "
|
|
158
|
+
f"attribution={a.attribution} tools={a.tool_call_count}"
|
|
159
|
+
)
|
|
160
|
+
lines.append("")
|
|
161
|
+
lines.append("WORKTREES")
|
|
162
|
+
if not doc.worktrees:
|
|
163
|
+
lines.append(f" (none: {_safe(doc.git.error or 'git disabled')})")
|
|
164
|
+
for w in doc.worktrees:
|
|
165
|
+
marks = ",".join(w.stale_reasons)
|
|
166
|
+
sync = "" if w.ahead is None else f" +{w.ahead}/-{w.behind}"
|
|
167
|
+
lines.append(
|
|
168
|
+
f" {_safe(w.path)} [{_safe(w.branch or 'detached')}] "
|
|
169
|
+
f"dirty={w.dirty_count if w.dirty_count is not None else '?'}{sync} "
|
|
170
|
+
f"lane={w.lane} telemetry={w.telemetry}" + (f" STALE({marks})" if marks else "")
|
|
171
|
+
)
|
|
172
|
+
return "\n".join(lines)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def register(app: typer.Typer) -> None:
|
|
176
|
+
@app.command("status")
|
|
177
|
+
def status(
|
|
178
|
+
json_output: Annotated[bool, typer.Option("--json", help="Stable JSON output.")] = False,
|
|
179
|
+
repo: Annotated[
|
|
180
|
+
Path, typer.Option("--repo", help="Directory inside the git repo (default: cwd).")
|
|
181
|
+
] = Path(),
|
|
182
|
+
stale_after_minutes: Annotated[
|
|
183
|
+
int, typer.Option("--stale-after-minutes", min=1, help="Silence before stale_offline.")
|
|
184
|
+
] = 15,
|
|
185
|
+
no_git: Annotated[bool, typer.Option("--no-git", help="Skip the git collector.")] = False,
|
|
186
|
+
now: Annotated[
|
|
187
|
+
str | None, typer.Option("--now", hidden=True, help="ISO-8601 clock for tests.")
|
|
188
|
+
] = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""Show the fleet: sessions, agents, lanes and worktrees. Read-only; no network."""
|
|
191
|
+
try:
|
|
192
|
+
clock = parse_now(now)
|
|
193
|
+
except ValueError as exc:
|
|
194
|
+
typer.echo(f"error: {exc}", err=True)
|
|
195
|
+
raise typer.Exit(2) from exc
|
|
196
|
+
try:
|
|
197
|
+
ctx = resolve_repo(repo)
|
|
198
|
+
except NotAGitRepo as exc:
|
|
199
|
+
if json_output:
|
|
200
|
+
typer.echo(
|
|
201
|
+
json.dumps({"schema": STATUS_SCHEMA, "error": {"code": "not_a_git_repo"}})
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
typer.echo(f"error: {_safe(exc)}", err=True)
|
|
205
|
+
raise typer.Exit(1) from exc
|
|
206
|
+
doc = build(ctx, now=clock, stale_after_s=stale_after_minutes * 60, with_git=not no_git)
|
|
207
|
+
if json_output:
|
|
208
|
+
typer.echo(json.dumps(doc.to_json_dict(), indent=2, sort_keys=False))
|
|
209
|
+
else:
|
|
210
|
+
typer.echo(_render_text(doc))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""``cursorfleet tui``: the Textual dashboard (read-only; no network)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
DEFAULT_REFRESH_S = 2.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def register(app: typer.Typer) -> None:
|
|
15
|
+
@app.command("tui")
|
|
16
|
+
def tui(
|
|
17
|
+
path: Annotated[
|
|
18
|
+
Path, typer.Option("--path", help="A directory inside the git repo (default: cwd).")
|
|
19
|
+
] = Path(),
|
|
20
|
+
refresh: Annotated[
|
|
21
|
+
float,
|
|
22
|
+
typer.Option(
|
|
23
|
+
"--refresh", min=0.25, max=60.0, help="Seconds between re-reads of the spool."
|
|
24
|
+
),
|
|
25
|
+
] = DEFAULT_REFRESH_S,
|
|
26
|
+
no_git: Annotated[
|
|
27
|
+
bool, typer.Option("--no-git", help="Do not run the read-only git collector.")
|
|
28
|
+
] = False,
|
|
29
|
+
no_watch: Annotated[
|
|
30
|
+
bool, typer.Option("--no-watch", help="Poll only; ignore watchfiles if installed.")
|
|
31
|
+
] = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
"""Open the dashboard: workflow lanes, agents, timeline, worktrees and gates.
|
|
34
|
+
|
|
35
|
+
Observe-only: it reads the SQLite projection and read-only git state, never fetches,
|
|
36
|
+
never enforces anything and never shows prompts, thinking or file contents.
|
|
37
|
+
"""
|
|
38
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
39
|
+
typer.echo(
|
|
40
|
+
"error: `cursorfleet tui` needs an interactive terminal. "
|
|
41
|
+
"Use `cursorfleet status` (or `status --json`) in scripts.",
|
|
42
|
+
err=True,
|
|
43
|
+
)
|
|
44
|
+
raise typer.Exit(2)
|
|
45
|
+
from cursorfleet.tui.app import build_app # noqa: PLC0415 - lazy: keeps --help fast
|
|
46
|
+
|
|
47
|
+
build_app(path.resolve(), refresh_s=refresh, with_git=not no_git, watch=not no_watch).run()
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""``cursorfleet uninstall``: remove exactly what the install lockfile recorded."""
|
|
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.cli.commands._common import fail, show_plan, workspace_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def register(app: typer.Typer) -> None:
|
|
16
|
+
@app.command("uninstall")
|
|
17
|
+
def uninstall(
|
|
18
|
+
dry_run: Annotated[
|
|
19
|
+
bool, typer.Option("--dry-run", help="Print the diff and write nothing.")
|
|
20
|
+
] = False,
|
|
21
|
+
yes: Annotated[
|
|
22
|
+
bool, typer.Option("--yes", "-y", help="Skip the prompt (the diff is still printed).")
|
|
23
|
+
] = False,
|
|
24
|
+
force: Annotated[
|
|
25
|
+
bool,
|
|
26
|
+
typer.Option("--force", help="Also remove installed items that were modified since."),
|
|
27
|
+
] = False,
|
|
28
|
+
path: Annotated[
|
|
29
|
+
Path, typer.Option("--path", help="Repository root (default: current directory).")
|
|
30
|
+
] = Path(),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Remove the files, AGENTS.md blocks and hook entries CursorFleet installed.
|
|
33
|
+
|
|
34
|
+
Only items listed in .cursorfleet/install.lock.json are touched, and only if
|
|
35
|
+
they still match their recorded hash. Modified items are reported and skipped
|
|
36
|
+
(unless --force). Your own hooks and AGENTS.md content are left as they were.
|
|
37
|
+
"""
|
|
38
|
+
root = workspace_root(path, command="uninstall")
|
|
39
|
+
plan = installer.build_uninstall_plan(root, force=force)
|
|
40
|
+
if plan.conflicts:
|
|
41
|
+
typer.echo("Cannot uninstall (nothing was written):", err=True)
|
|
42
|
+
for conflict in plan.conflicts:
|
|
43
|
+
typer.echo(f" - {safe_text(conflict)}", err=True)
|
|
44
|
+
raise typer.Exit(1)
|
|
45
|
+
if plan.noop and not plan.drift:
|
|
46
|
+
for note in plan.notes:
|
|
47
|
+
typer.echo(safe_text(note))
|
|
48
|
+
if not plan.notes:
|
|
49
|
+
typer.echo("Nothing to remove.")
|
|
50
|
+
return
|
|
51
|
+
if not plan.noop:
|
|
52
|
+
show_plan(plan)
|
|
53
|
+
for item in plan.drift:
|
|
54
|
+
typer.echo(f"drift: {safe_text(item)}", err=True)
|
|
55
|
+
if dry_run:
|
|
56
|
+
typer.echo("\nDry run: no files were written.")
|
|
57
|
+
raise typer.Exit(1 if plan.drift else 0)
|
|
58
|
+
if not plan.noop:
|
|
59
|
+
if not yes and not typer.confirm("\nApply these changes?", default=False):
|
|
60
|
+
typer.echo("Aborted; nothing was written.")
|
|
61
|
+
raise typer.Exit(1)
|
|
62
|
+
try:
|
|
63
|
+
installer.apply_plan(plan)
|
|
64
|
+
except (OSError, UnsafePathError) as exc:
|
|
65
|
+
raise fail(f"error while writing: {exc}") from exc
|
|
66
|
+
if plan.drift:
|
|
67
|
+
typer.echo(
|
|
68
|
+
"\nUninstalled everything that still matched. Drifted items remain and stay "
|
|
69
|
+
"listed in the lockfile; fix them or re-run with --force.",
|
|
70
|
+
err=True,
|
|
71
|
+
)
|
|
72
|
+
raise typer.Exit(1)
|
|
73
|
+
typer.echo("\nUninstalled.")
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""``cursorfleet validate``: config, roster, generated kit, rules and artifacts."""
|
|
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.adapters.cursor.validation import Finding, validate_workspace
|
|
13
|
+
from cursorfleet.cli.commands._resolution import (
|
|
14
|
+
build_resolution,
|
|
15
|
+
render_resolution_text,
|
|
16
|
+
resolution_as_dict,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register(app: typer.Typer) -> None:
|
|
21
|
+
@app.command("validate")
|
|
22
|
+
def validate(
|
|
23
|
+
json_output: Annotated[
|
|
24
|
+
bool, typer.Option("--json", help="Machine-readable output.")
|
|
25
|
+
] = False,
|
|
26
|
+
path: Annotated[
|
|
27
|
+
Path, typer.Option("--path", help="Directory inside the git repo (default: current).")
|
|
28
|
+
] = Path(),
|
|
29
|
+
) -> None:
|
|
30
|
+
"""Validate .cursorfleet config and roster, the generated kit and work artifacts.
|
|
31
|
+
|
|
32
|
+
Exit code 1 if any error is found; warnings do not fail the command.
|
|
33
|
+
"""
|
|
34
|
+
resolution = build_resolution(path)
|
|
35
|
+
root = Path(resolution.top_level) if resolution.top_level else path.expanduser().resolve()
|
|
36
|
+
findings, stats = validate_workspace(root)
|
|
37
|
+
if resolution.status == "fail":
|
|
38
|
+
findings.insert(
|
|
39
|
+
0,
|
|
40
|
+
Finding(
|
|
41
|
+
"error",
|
|
42
|
+
"repo.resolution",
|
|
43
|
+
resolution.reason,
|
|
44
|
+
hint="doctor and validate never walk past an inner Git boundary "
|
|
45
|
+
"and never write.",
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
errors = sum(1 for f in findings if f.severity == "error")
|
|
49
|
+
warnings = len(findings) - errors
|
|
50
|
+
if json_output:
|
|
51
|
+
payload = {
|
|
52
|
+
"schema": "cursorfleet.validate/1",
|
|
53
|
+
"ok": errors == 0,
|
|
54
|
+
"counts": {"error": errors, "warning": warnings},
|
|
55
|
+
"stats": stats,
|
|
56
|
+
"resolution": resolution_as_dict(resolution),
|
|
57
|
+
"findings": [f.as_dict() for f in findings],
|
|
58
|
+
}
|
|
59
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
60
|
+
else:
|
|
61
|
+
typer.echo(render_resolution_text(resolution))
|
|
62
|
+
for f in findings:
|
|
63
|
+
where = f" {safe_text(f.path)}" if f.path else ""
|
|
64
|
+
typer.echo(f"[{f.severity}] {f.code}{where}: {safe_text(f.message)}")
|
|
65
|
+
if f.hint:
|
|
66
|
+
typer.echo(f" fix: {safe_text(f.hint)}")
|
|
67
|
+
typer.echo(f"{errors} error(s), {warnings} warning(s)")
|
|
68
|
+
if errors:
|
|
69
|
+
raise typer.Exit(1)
|
cursorfleet/cli/main.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""CursorFleet CLI entry point.
|
|
2
|
+
|
|
3
|
+
Command registration pattern
|
|
4
|
+
----------------------------
|
|
5
|
+
Each command group lives in its own module under ``cursorfleet.cli.commands``
|
|
6
|
+
and exposes ``register(app: typer.Typer) -> None``. Modules are discovered and
|
|
7
|
+
imported by :func:`register_commands` below, so adding a command group means
|
|
8
|
+
adding one new file and never editing this one (no merge conflicts between
|
|
9
|
+
phases). Modules are loaded in alphabetical order for deterministic help
|
|
10
|
+
output.
|
|
11
|
+
|
|
12
|
+
Example (``cursorfleet/cli/commands/doctor.py``)::
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
def register(app: typer.Typer) -> None:
|
|
17
|
+
@app.command()
|
|
18
|
+
def doctor() -> None:
|
|
19
|
+
...
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import importlib
|
|
25
|
+
import pkgutil
|
|
26
|
+
|
|
27
|
+
import typer
|
|
28
|
+
|
|
29
|
+
from cursorfleet import __version__
|
|
30
|
+
from cursorfleet.cli import commands as _commands_pkg
|
|
31
|
+
|
|
32
|
+
app = typer.Typer(
|
|
33
|
+
name="cursorfleet",
|
|
34
|
+
help="A local TUI and workflow harness for Cursor agent teams.",
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
add_completion=False,
|
|
37
|
+
# An unexpected error must not dump local variables (paths, config text, spool data)
|
|
38
|
+
# into a pasted traceback (security review SR-08).
|
|
39
|
+
pretty_exceptions_show_locals=False,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _version_callback(value: bool) -> None:
|
|
44
|
+
if value:
|
|
45
|
+
typer.echo(f"cursorfleet {__version__}")
|
|
46
|
+
raise typer.Exit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@app.callback()
|
|
50
|
+
def _root(
|
|
51
|
+
version: bool = typer.Option(
|
|
52
|
+
False,
|
|
53
|
+
"--version",
|
|
54
|
+
callback=_version_callback,
|
|
55
|
+
is_eager=True,
|
|
56
|
+
help="Show the version and exit.",
|
|
57
|
+
),
|
|
58
|
+
) -> None:
|
|
59
|
+
"""A local TUI and workflow harness for Cursor agent teams."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def register_commands(target: typer.Typer) -> None:
|
|
63
|
+
"""Import every module in ``cursorfleet.cli.commands`` and call its ``register``."""
|
|
64
|
+
for info in sorted(pkgutil.iter_modules(_commands_pkg.__path__), key=lambda m: m.name):
|
|
65
|
+
if info.name.startswith("_"):
|
|
66
|
+
continue
|
|
67
|
+
module = importlib.import_module(f"{_commands_pkg.__name__}.{info.name}")
|
|
68
|
+
register = getattr(module, "register", None)
|
|
69
|
+
if callable(register):
|
|
70
|
+
register(target)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
register_commands(app)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main() -> None:
|
|
77
|
+
app()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Configuration and roster models.
|
|
2
|
+
|
|
3
|
+
Importing this package is cheap; models (Pydantic) are re-exported lazily
|
|
4
|
+
(PEP 562). Import from ``cursorfleet.config.models`` / ``.roster`` / ``.io``
|
|
5
|
+
for explicit, eager access.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from importlib import import_module
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from cursorfleet.config.io import loads_config, loads_roster
|
|
15
|
+
from cursorfleet.config.models import FleetConfig
|
|
16
|
+
from cursorfleet.config.roster import AgentSpec, CoordinatorSpec, Roster, default_roster
|
|
17
|
+
|
|
18
|
+
_LAZY = {
|
|
19
|
+
"FleetConfig": "cursorfleet.config.models",
|
|
20
|
+
"AgentSpec": "cursorfleet.config.roster",
|
|
21
|
+
"CoordinatorSpec": "cursorfleet.config.roster",
|
|
22
|
+
"Roster": "cursorfleet.config.roster",
|
|
23
|
+
"default_roster": "cursorfleet.config.roster",
|
|
24
|
+
"loads_config": "cursorfleet.config.io",
|
|
25
|
+
"loads_roster": "cursorfleet.config.io",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"AgentSpec",
|
|
30
|
+
"CoordinatorSpec",
|
|
31
|
+
"FleetConfig",
|
|
32
|
+
"Roster",
|
|
33
|
+
"default_roster",
|
|
34
|
+
"loads_config",
|
|
35
|
+
"loads_roster",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def __getattr__(name: str) -> Any:
|
|
40
|
+
module = _LAZY.get(name)
|
|
41
|
+
if module is None:
|
|
42
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
43
|
+
raise AttributeError(msg)
|
|
44
|
+
return getattr(import_module(module), name)
|
cursorfleet/config/io.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Parse ``config.toml`` / ``roster.toml`` text into models. Size-capped, stdlib TOML."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
|
|
7
|
+
from cursorfleet.config.models import FleetConfig
|
|
8
|
+
from cursorfleet.config.roster import Roster
|
|
9
|
+
|
|
10
|
+
MAX_TOML_BYTES = 256 * 1024
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _parse(text: str) -> dict[str, object]:
|
|
14
|
+
if len(text.encode("utf-8")) > MAX_TOML_BYTES:
|
|
15
|
+
msg = f"TOML input exceeds {MAX_TOML_BYTES} bytes"
|
|
16
|
+
raise ValueError(msg)
|
|
17
|
+
return tomllib.loads(text)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def loads_config(text: str) -> FleetConfig:
|
|
21
|
+
"""Parse and validate ``config.toml`` text. Raises ValueError subclasses on any problem."""
|
|
22
|
+
return FleetConfig.model_validate(_parse(text))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def loads_roster(text: str) -> Roster:
|
|
26
|
+
"""Parse and validate ``roster.toml`` text. Raises ValueError subclasses on any problem."""
|
|
27
|
+
return Roster.model_validate(_parse(text))
|