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