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
|
+
"""Render the Cursor kit (subagents, skills, rules, AGENTS.md blocks) from the roster.
|
|
2
|
+
|
|
3
|
+
Pure and deterministic: the same roster, config and templates always yield the
|
|
4
|
+
same bytes (no timestamps, no versions, no environment). Nothing here touches
|
|
5
|
+
the filesystem except reading packaged templates.
|
|
6
|
+
|
|
7
|
+
Frontmatter written for subagents is limited to ``name``, ``description``,
|
|
8
|
+
``model``, ``readonly`` and ``is_background`` (ADR 0001, A7). ``capability_notes``
|
|
9
|
+
in the roster are documentation only and are never rendered as if enforced.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
from cursorfleet.adapters.cursor import blocks
|
|
18
|
+
from cursorfleet.adapters.cursor.templates import read_template, render
|
|
19
|
+
from cursorfleet.config.models import FleetConfig
|
|
20
|
+
from cursorfleet.config.roster import AgentSpec, Role, Roster
|
|
21
|
+
from cursorfleet.workflow.frontmatter import yaml_string
|
|
22
|
+
|
|
23
|
+
ROOT_AGENTS_MD = "AGENTS.md"
|
|
24
|
+
CONFIG_PATH = ".cursorfleet/config.toml"
|
|
25
|
+
ROSTER_PATH = ".cursorfleet/roster.toml"
|
|
26
|
+
LOCK_PATH = ".cursorfleet/install.lock.json"
|
|
27
|
+
HOOKS_PATH = ".cursor/hooks.json"
|
|
28
|
+
AGENTS_DIR = ".cursor/agents"
|
|
29
|
+
RULES_DIR = ".cursor/rules"
|
|
30
|
+
SKILLS_DIR = ".cursor/skills"
|
|
31
|
+
COORDINATOR_SKILL = "cursorfleet-coordinator"
|
|
32
|
+
ARTIFACTS_SKILL = "cursorfleet-artifacts"
|
|
33
|
+
SUBAGENT_FRONTMATTER_KEYS: tuple[str, ...] = (
|
|
34
|
+
"name",
|
|
35
|
+
"description",
|
|
36
|
+
"model",
|
|
37
|
+
"readonly",
|
|
38
|
+
"is_background",
|
|
39
|
+
)
|
|
40
|
+
_COORDINATOR_SUFFIX = (
|
|
41
|
+
" Use for planned, reviewed or parallel multi-agent changes; skip it for small "
|
|
42
|
+
"single-file edits."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Kit:
|
|
48
|
+
"""Everything ``init`` generates, keyed by workspace-relative POSIX path."""
|
|
49
|
+
|
|
50
|
+
files: dict[str, str] = field(default_factory=dict) # whole files CursorFleet owns
|
|
51
|
+
blocks: dict[str, str] = field(default_factory=dict) # managed blocks in shared files
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def agent_file_path(roster: Roster, agent: AgentSpec) -> str:
|
|
55
|
+
return f"{AGENTS_DIR}/{roster.cursor_name(agent.id)}.md"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _first_name(roster: Roster, role: Role) -> str:
|
|
59
|
+
for agent in roster.enabled_agents():
|
|
60
|
+
if agent.role is role:
|
|
61
|
+
return f"`{roster.cursor_name(agent.id)}`"
|
|
62
|
+
return f"(no enabled {role.value} agent)"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _cell(text: str) -> str:
|
|
66
|
+
return " ".join(text.split()).replace("|", "\\|")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def roster_table(roster: Roster) -> str:
|
|
70
|
+
rows = ["| Subagent | Role | Access | Use for |", "| --- | --- | --- | --- |"]
|
|
71
|
+
for agent in roster.enabled_agents():
|
|
72
|
+
access = "read-only" if agent.readonly else "can edit"
|
|
73
|
+
name = roster.cursor_name(agent.id)
|
|
74
|
+
rows.append(f"| `{name}` | {agent.role.value} | {access} | {_cell(agent.description)} |")
|
|
75
|
+
return "\n".join(rows)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _values(roster: Roster, config: FleetConfig, agent: AgentSpec | None) -> dict[str, str]:
|
|
79
|
+
values: dict[str, str] = {
|
|
80
|
+
"work_dir": config.work.dir,
|
|
81
|
+
"name_prefix": roster.name_prefix,
|
|
82
|
+
"roster_table": roster_table(roster),
|
|
83
|
+
"coordinator_description": yaml_string(
|
|
84
|
+
roster.coordinator.description.rstrip() + _COORDINATOR_SUFFIX
|
|
85
|
+
),
|
|
86
|
+
"name": "",
|
|
87
|
+
"id": "",
|
|
88
|
+
}
|
|
89
|
+
for role in Role:
|
|
90
|
+
if role is not Role.COORDINATOR:
|
|
91
|
+
values[f"role.{role.value}"] = _first_name(roster, role)
|
|
92
|
+
if agent is not None:
|
|
93
|
+
values["name"] = f"`{roster.cursor_name(agent.id)}`"
|
|
94
|
+
values["id"] = agent.id
|
|
95
|
+
return values
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_agent(roster: Roster, config: FleetConfig, agent: AgentSpec) -> str:
|
|
99
|
+
"""Render one ``.cursor/agents/<name>.md`` file (frontmatter limited to 5 keys)."""
|
|
100
|
+
name = roster.cursor_name(agent.id)
|
|
101
|
+
front = [
|
|
102
|
+
"---",
|
|
103
|
+
f"name: {name}",
|
|
104
|
+
f"description: {yaml_string(agent.description)}",
|
|
105
|
+
f"model: {yaml_string(agent.model) if agent.model != 'inherit' else 'inherit'}",
|
|
106
|
+
f"readonly: {'true' if agent.readonly else 'false'}",
|
|
107
|
+
f"is_background: {'true' if agent.is_background else 'false'}",
|
|
108
|
+
"---",
|
|
109
|
+
"",
|
|
110
|
+
"",
|
|
111
|
+
]
|
|
112
|
+
body = render(read_template(f"agents/{agent.role.value}.md"), _values(roster, config, agent))
|
|
113
|
+
return "\n".join(front)[:-1] + body.rstrip("\n") + "\n"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _coordinator_body(roster: Roster, config: FleetConfig) -> str:
|
|
117
|
+
template = read_template(f"skills/{COORDINATOR_SKILL}/SKILL.md")
|
|
118
|
+
return render(template, _values(roster, config, None))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _skill_to_rule(skill_text: str, description: str) -> str:
|
|
122
|
+
"""Turn the coordinator SKILL.md into an on-demand rule (same body, rule frontmatter)."""
|
|
123
|
+
body = skill_text.split("\n---\n", 1)[1].lstrip("\n")
|
|
124
|
+
front = f"---\ndescription: {description}\nalwaysApply: false\n---\n\n"
|
|
125
|
+
return front + body
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def build_kit(roster: Roster, config: FleetConfig) -> Kit:
|
|
129
|
+
"""Build the full kit for ``roster`` and ``config``."""
|
|
130
|
+
files: dict[str, str] = {}
|
|
131
|
+
for agent in roster.enabled_agents():
|
|
132
|
+
files[agent_file_path(roster, agent)] = render_agent(roster, config, agent)
|
|
133
|
+
|
|
134
|
+
values = _values(roster, config, None)
|
|
135
|
+
files[f"{SKILLS_DIR}/{ARTIFACTS_SKILL}/SKILL.md"] = render(
|
|
136
|
+
read_template(f"skills/{ARTIFACTS_SKILL}/SKILL.md"), values
|
|
137
|
+
)
|
|
138
|
+
files[f"{RULES_DIR}/cursorfleet-core.mdc"] = render(
|
|
139
|
+
read_template("rules/cursorfleet-core.mdc"), values
|
|
140
|
+
)
|
|
141
|
+
files[f"{RULES_DIR}/cursorfleet-handoff.mdc"] = render(
|
|
142
|
+
read_template("rules/cursorfleet-handoff.mdc"), values
|
|
143
|
+
)
|
|
144
|
+
coordinator = roster.coordinator
|
|
145
|
+
if coordinator.enabled:
|
|
146
|
+
skill_text = _coordinator_body(roster, config)
|
|
147
|
+
if coordinator.delivery in {"skill", "both"}:
|
|
148
|
+
files[f"{SKILLS_DIR}/{COORDINATOR_SKILL}/SKILL.md"] = skill_text
|
|
149
|
+
if coordinator.delivery in {"rule", "both"}:
|
|
150
|
+
files[f"{RULES_DIR}/cursorfleet-coordinator.mdc"] = _skill_to_rule(
|
|
151
|
+
skill_text, values["coordinator_description"]
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
kit_blocks = {
|
|
155
|
+
ROOT_AGENTS_MD: blocks.make_block(render(read_template("agents-md/root.md"), values)),
|
|
156
|
+
f"{config.work.dir}/AGENTS.md": blocks.make_block(
|
|
157
|
+
render(read_template("agents-md/work.md"), values)
|
|
158
|
+
),
|
|
159
|
+
}
|
|
160
|
+
return Kit(files=dict(sorted(files.items())), blocks=kit_blocks)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# ---- seed files: written only if absent, then owned by the user -------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def seed_config_text() -> str:
|
|
167
|
+
return read_template("config/config.toml")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _toml_str(value: str) -> str:
|
|
171
|
+
return json.dumps(value, ensure_ascii=False)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _toml_value(value: object) -> str:
|
|
175
|
+
if isinstance(value, bool):
|
|
176
|
+
return "true" if value else "false"
|
|
177
|
+
if isinstance(value, int):
|
|
178
|
+
return str(value)
|
|
179
|
+
if isinstance(value, str):
|
|
180
|
+
return _toml_str(value)
|
|
181
|
+
if isinstance(value, list):
|
|
182
|
+
if not value:
|
|
183
|
+
return "[]"
|
|
184
|
+
items = "".join(f" {_toml_value(v)},\n" for v in value)
|
|
185
|
+
return f"[\n{items}]"
|
|
186
|
+
msg = f"unsupported TOML value: {type(value).__name__}"
|
|
187
|
+
raise TypeError(msg)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _toml_table(data: dict[str, object]) -> list[str]:
|
|
191
|
+
return [f"{key} = {_toml_value(value)}" for key, value in data.items()]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def seed_roster_text(roster: Roster) -> str:
|
|
195
|
+
"""Render ``roster`` as TOML (the roster has only scalars, string lists and 2 table kinds)."""
|
|
196
|
+
dumped = roster.model_dump(mode="json")
|
|
197
|
+
out = [
|
|
198
|
+
"# CursorFleet roster (committed; data only, never executed).",
|
|
199
|
+
"# Source of truth for the generated .cursor/agents/*.md files: edit this file,",
|
|
200
|
+
"# then run `cursorfleet init --cursor` again. `capability_notes` are documentation",
|
|
201
|
+
"# only; Cursor cannot enforce per-agent tools, paths or network, and v0.1 does not.",
|
|
202
|
+
f"schema_version = {_toml_str(dumped['schema_version'])}",
|
|
203
|
+
f"name_prefix = {_toml_str(dumped['name_prefix'])}",
|
|
204
|
+
"",
|
|
205
|
+
"[coordinator]",
|
|
206
|
+
*_toml_table(dumped["coordinator"]),
|
|
207
|
+
]
|
|
208
|
+
for agent in dumped["agents"]:
|
|
209
|
+
out += ["", "[[agents]]", *_toml_table(agent)]
|
|
210
|
+
return "\n".join(out) + "\n"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Read-only probe: is the observe-only hook kit present in a workspace?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from cursorfleet.adapters.cursor.hooksjson import HOOK_COMMAND, effective_events
|
|
10
|
+
from cursorfleet.adapters.cursor.kit import HOOKS_PATH, LOCK_PATH
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HooksKitState(StrEnum):
|
|
14
|
+
"""How much of ``cursorfleet init --cursor`` is visible on disk."""
|
|
15
|
+
|
|
16
|
+
MISSING = "missing" # no lock and no project cursorfleet-hook entries
|
|
17
|
+
PARTIAL = "partial" # lock or hooks alone, or a broken hooks.json
|
|
18
|
+
INSTALLED = "installed" # lockfile plus cursorfleet-hook entries in project hooks.json
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class HooksKitProbe:
|
|
23
|
+
state: HooksKitState
|
|
24
|
+
lock_present: bool
|
|
25
|
+
project_hooks_path: str | None
|
|
26
|
+
cursorfleet_hook_entries: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def probe_hooks_kit(repo_root: str | None) -> HooksKitProbe:
|
|
30
|
+
"""Never raises; safe for the TUI worker and ``status``."""
|
|
31
|
+
if not repo_root:
|
|
32
|
+
return HooksKitProbe(HooksKitState.MISSING, False, None, 0)
|
|
33
|
+
root = Path(repo_root)
|
|
34
|
+
lock_present = (root / LOCK_PATH).is_file()
|
|
35
|
+
hooks_path = root / HOOKS_PATH
|
|
36
|
+
entries = 0
|
|
37
|
+
project_hooks: str | None = None
|
|
38
|
+
if hooks_path.is_file():
|
|
39
|
+
project_hooks = HOOKS_PATH
|
|
40
|
+
try:
|
|
41
|
+
parsed = effective_events(hooks_path.read_text(encoding="utf-8"))
|
|
42
|
+
except OSError:
|
|
43
|
+
parsed = {}
|
|
44
|
+
for hook_entries in parsed.values():
|
|
45
|
+
for item in hook_entries:
|
|
46
|
+
if item.get("command") == HOOK_COMMAND:
|
|
47
|
+
entries += 1
|
|
48
|
+
if lock_present and entries > 0:
|
|
49
|
+
state = HooksKitState.INSTALLED
|
|
50
|
+
elif lock_present or entries > 0:
|
|
51
|
+
state = HooksKitState.PARTIAL
|
|
52
|
+
else:
|
|
53
|
+
state = HooksKitState.MISSING
|
|
54
|
+
return HooksKitProbe(state, lock_present, project_hooks, entries)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""The install lockfile ``.cursorfleet/install.lock.json``.
|
|
2
|
+
|
|
3
|
+
Records exactly what ``init`` wrote (paths, sha256, managed blocks, hook entries,
|
|
4
|
+
directories it created) so ``uninstall`` removes only that and ``doctor`` can
|
|
5
|
+
report drift. It contains no timestamps, so re-running ``init`` is a no-op.
|
|
6
|
+
|
|
7
|
+
The lock is data from the repository and therefore untrusted: every path is
|
|
8
|
+
validated against an allowlist of managed locations before any use.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from typing import Any, Literal
|
|
15
|
+
|
|
16
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
17
|
+
|
|
18
|
+
from cursorfleet.adapters.cursor.hooksjson import JsonStyle
|
|
19
|
+
from cursorfleet.adapters.cursor.kit import HOOKS_PATH, LOCK_PATH
|
|
20
|
+
from cursorfleet.events.pathcheck import check_not_in_git_dir, check_relative_posix
|
|
21
|
+
|
|
22
|
+
_STRICT = ConfigDict(extra="forbid", frozen=True)
|
|
23
|
+
_FILE_PREFIXES = (".cursor/agents/", ".cursor/rules/", ".cursor/skills/", ".cursorfleet/")
|
|
24
|
+
_SHA256 = r"^[0-9a-f]{64}$"
|
|
25
|
+
MAX_LOCK_BYTES = 256 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_managed_file_path(path: str) -> bool:
|
|
29
|
+
"""True for paths CursorFleet may create as whole files."""
|
|
30
|
+
try:
|
|
31
|
+
check_not_in_git_dir(check_relative_posix(path))
|
|
32
|
+
except ValueError:
|
|
33
|
+
return False
|
|
34
|
+
return path.startswith(_FILE_PREFIXES) and path != LOCK_PATH
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_managed_block_path(path: str) -> bool:
|
|
38
|
+
"""True for ``AGENTS.md`` files (root or nested) that may hold a managed block."""
|
|
39
|
+
try:
|
|
40
|
+
check_not_in_git_dir(check_relative_posix(path))
|
|
41
|
+
except ValueError:
|
|
42
|
+
return False
|
|
43
|
+
return path == "AGENTS.md" or path.endswith("/AGENTS.md")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class LockFile(BaseModel):
|
|
47
|
+
model_config = _STRICT
|
|
48
|
+
|
|
49
|
+
path: str
|
|
50
|
+
sha256: str = Field(pattern=_SHA256)
|
|
51
|
+
kind: Literal["file", "seed"] = "file" # seed = user-owned after creation (config, roster)
|
|
52
|
+
adopted: bool = False # pre-existed with identical content; never removed
|
|
53
|
+
|
|
54
|
+
@field_validator("path")
|
|
55
|
+
@classmethod
|
|
56
|
+
def _path(cls, value: str) -> str:
|
|
57
|
+
if not is_managed_file_path(value):
|
|
58
|
+
msg = f"not a managed file location: {value!r}"
|
|
59
|
+
raise ValueError(msg)
|
|
60
|
+
return value
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class LockBlock(BaseModel):
|
|
64
|
+
model_config = _STRICT
|
|
65
|
+
|
|
66
|
+
path: str
|
|
67
|
+
sha256: str = Field(pattern=_SHA256) # of the block text (LF-normalized)
|
|
68
|
+
prefix: str = Field(default="", max_length=4) # separator inserted before the block
|
|
69
|
+
file_created: bool = False
|
|
70
|
+
|
|
71
|
+
@field_validator("path")
|
|
72
|
+
@classmethod
|
|
73
|
+
def _path(cls, value: str) -> str:
|
|
74
|
+
if not is_managed_block_path(value):
|
|
75
|
+
msg = f"not a managed AGENTS.md location: {value!r}"
|
|
76
|
+
raise ValueError(msg)
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
@field_validator("prefix")
|
|
80
|
+
@classmethod
|
|
81
|
+
def _prefix(cls, value: str) -> str:
|
|
82
|
+
if value.replace("\r", "").replace("\n", "") != "":
|
|
83
|
+
msg = "prefix may only contain newlines"
|
|
84
|
+
raise ValueError(msg)
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class LockHooks(BaseModel):
|
|
89
|
+
model_config = _STRICT
|
|
90
|
+
|
|
91
|
+
path: str = HOOKS_PATH
|
|
92
|
+
created: bool = False # file did not exist before init
|
|
93
|
+
added_version: bool = False
|
|
94
|
+
added_hooks_key: bool = False
|
|
95
|
+
added_events: list[str] = Field(default_factory=list)
|
|
96
|
+
entries: dict[str, dict[str, Any]] = Field(default_factory=dict)
|
|
97
|
+
style: JsonStyle = Field(default_factory=JsonStyle)
|
|
98
|
+
installed_sha256: str = Field(pattern=_SHA256)
|
|
99
|
+
original_sha256: str | None = Field(default=None, pattern=_SHA256)
|
|
100
|
+
original_b64: str | None = Field(default=None, max_length=200_000)
|
|
101
|
+
|
|
102
|
+
@field_validator("path")
|
|
103
|
+
@classmethod
|
|
104
|
+
def _path(cls, value: str) -> str:
|
|
105
|
+
if value != HOOKS_PATH:
|
|
106
|
+
msg = "hooks path must be .cursor/hooks.json"
|
|
107
|
+
raise ValueError(msg)
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class InstallLock(BaseModel):
|
|
112
|
+
model_config = _STRICT
|
|
113
|
+
|
|
114
|
+
lock_version: Literal["1"] = "1"
|
|
115
|
+
cursorfleet_version: str = Field(max_length=64)
|
|
116
|
+
files: list[LockFile] = Field(default_factory=list)
|
|
117
|
+
blocks: list[LockBlock] = Field(default_factory=list)
|
|
118
|
+
hooks: LockHooks | None = None
|
|
119
|
+
dirs_created: list[str] = Field(default_factory=list)
|
|
120
|
+
|
|
121
|
+
@field_validator("dirs_created")
|
|
122
|
+
@classmethod
|
|
123
|
+
def _dirs(cls, value: list[str]) -> list[str]:
|
|
124
|
+
for item in value:
|
|
125
|
+
check_not_in_git_dir(check_relative_posix(item)) # only empty dirs are ever removed
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
def file_map(self) -> dict[str, LockFile]:
|
|
129
|
+
return {f.path: f for f in self.files}
|
|
130
|
+
|
|
131
|
+
def block_map(self) -> dict[str, LockBlock]:
|
|
132
|
+
return {b.path: b for b in self.blocks}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def dumps_lock(lock: InstallLock) -> str:
|
|
136
|
+
data = lock.model_dump(mode="json")
|
|
137
|
+
return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def loads_lock(text: str) -> InstallLock:
|
|
141
|
+
"""Parse lock text. Raises ``ValueError`` (including pydantic's) on any problem."""
|
|
142
|
+
if len(text.encode("utf-8")) > MAX_LOCK_BYTES:
|
|
143
|
+
msg = "install lockfile is too large"
|
|
144
|
+
raise ValueError(msg)
|
|
145
|
+
return InstallLock.model_validate_json(text)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Locate and render the ``templates/cursor`` kit templates.
|
|
2
|
+
|
|
3
|
+
Installed wheels carry the templates as package data at
|
|
4
|
+
``cursorfleet/_templates/cursor`` (hatch ``force-include``, see pyproject.toml).
|
|
5
|
+
In a source checkout they live in ``<repo>/templates/cursor``. Both are read
|
|
6
|
+
through :mod:`importlib.resources`-style traversables, so zip installs work too.
|
|
7
|
+
|
|
8
|
+
Rendering is plain ``{{placeholder}}`` substitution with no logic; unknown or
|
|
9
|
+
unfilled placeholders are errors, which keeps generated output deterministic.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from importlib import resources
|
|
16
|
+
from importlib.resources.abc import Traversable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_PLACEHOLDER = re.compile(r"\{\{([A-Za-z0-9_.]+)\}\}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TemplateError(RuntimeError):
|
|
23
|
+
"""A template is missing or references an unknown placeholder."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def templates_root() -> Traversable:
|
|
27
|
+
"""Return the root of the cursor templates (packaged copy first, repo copy second)."""
|
|
28
|
+
packaged = resources.files("cursorfleet").joinpath("_templates", "cursor")
|
|
29
|
+
if packaged.is_dir():
|
|
30
|
+
return packaged
|
|
31
|
+
# src/cursorfleet/adapters/cursor/templates.py -> repository root is parents[4]
|
|
32
|
+
repo = Path(__file__).resolve().parents[4] / "templates" / "cursor"
|
|
33
|
+
if repo.is_dir():
|
|
34
|
+
return repo
|
|
35
|
+
msg = "CursorFleet templates not found (broken install?)"
|
|
36
|
+
raise TemplateError(msg)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def read_template(rel: str) -> str:
|
|
40
|
+
"""Read template ``rel`` (POSIX path under templates/cursor) as LF-normalized text."""
|
|
41
|
+
node = templates_root()
|
|
42
|
+
for part in rel.split("/"):
|
|
43
|
+
node = node.joinpath(part)
|
|
44
|
+
if not node.is_file():
|
|
45
|
+
msg = f"missing template: {rel}"
|
|
46
|
+
raise TemplateError(msg)
|
|
47
|
+
return node.read_text(encoding="utf-8").replace("\r\n", "\n")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def render(template: str, values: dict[str, str]) -> str:
|
|
51
|
+
"""Substitute ``{{key}}`` placeholders; raise on unknown keys."""
|
|
52
|
+
|
|
53
|
+
def repl(match: re.Match[str]) -> str:
|
|
54
|
+
key = match.group(1)
|
|
55
|
+
if key not in values:
|
|
56
|
+
msg = f"unknown template placeholder: {{{{{key}}}}}"
|
|
57
|
+
raise TemplateError(msg)
|
|
58
|
+
return values[key]
|
|
59
|
+
|
|
60
|
+
return _PLACEHOLDER.sub(repl, template)
|