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
+ """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)