claude-dev-env 1.19.3 → 1.21.0

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.
@@ -0,0 +1,194 @@
1
+ """Sync Claude rules to Cursor .mdc files and manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+
11
+ from sync_to_cursor.canonical_docs import check_canonical_docs, sync_canonical_docs
12
+ from sync_to_cursor.config import GENERATOR_VERSION
13
+ from sync_to_cursor.hashing import sha256_bytes
14
+ from sync_to_cursor.paths import llm_layout_paths
15
+ from sync_to_cursor.rules import RuleMapping, _full_mdc, apply_transform, build_mappings
16
+
17
+
18
+ def _load_manifest(manifest_path: Path) -> dict:
19
+ if not manifest_path.is_file():
20
+ return {}
21
+ try:
22
+ return json.loads(manifest_path.read_text(encoding="utf-8"))
23
+ except json.JSONDecodeError:
24
+ return {}
25
+
26
+
27
+ def _sources_hash(paths: tuple[Path, ...]) -> str:
28
+ return sha256_bytes(b"\x00".join(p.read_bytes() for p in paths))
29
+
30
+
31
+ def _run_check(
32
+ mappings: tuple[RuleMapping, ...],
33
+ out_dir: Path,
34
+ entries_meta: dict,
35
+ claude: Path,
36
+ cursor: Path,
37
+ docs_entries_meta: dict,
38
+ ) -> int:
39
+ for each_mapping in mappings:
40
+ key = f"rules/{each_mapping.output_name}"
41
+ out_path = out_dir / each_mapping.output_name
42
+ missing = [source for source in each_mapping.sources if not source.is_file()]
43
+ if missing:
44
+ if each_mapping.always_apply:
45
+ return 1
46
+ continue
47
+ src_hash = _sources_hash(each_mapping.sources)
48
+ prev = entries_meta.get(key, {})
49
+ if src_hash != prev.get("sources_hash"):
50
+ return 1
51
+ prev_out = prev.get("output_hash", "")
52
+ if out_path.is_file() and prev_out:
53
+ if sha256_bytes(out_path.read_bytes()) != prev_out:
54
+ return 1
55
+ elif not out_path.is_file():
56
+ return 1
57
+ if out_dir.is_dir():
58
+ for each_path in out_dir.glob("*.mdc"):
59
+ if each_path.name not in {x.output_name for x in mappings}:
60
+ return 1
61
+ if not check_canonical_docs(claude, cursor, docs_entries_meta):
62
+ return 1
63
+ return 0
64
+
65
+
66
+ def _sync_rules(
67
+ mappings: tuple[RuleMapping, ...],
68
+ out_dir: Path,
69
+ entries_meta: dict,
70
+ *,
71
+ force: bool,
72
+ dry_run: bool,
73
+ quiet: bool,
74
+ cursor: Path,
75
+ ) -> tuple[dict, dict]:
76
+ summary: dict = {"skip": 0, "update": 0, "tampered": 0, "warn": 0, "orphan": 0}
77
+ new_entries: dict = {}
78
+ write_allowed = not dry_run
79
+
80
+ for each_mapping in mappings:
81
+ key = f"rules/{each_mapping.output_name}"
82
+ out_path = out_dir / each_mapping.output_name
83
+ missing = [source for source in each_mapping.sources if not source.is_file()]
84
+ if missing:
85
+ summary["warn"] += 1
86
+ if not quiet:
87
+ print(f"WARN rules/{each_mapping.output_name} (missing source: {missing})")
88
+ continue
89
+
90
+ src_hash = _sources_hash(each_mapping.sources)
91
+ prev = entries_meta.get(key, {})
92
+ prev_src = prev.get("sources_hash", "")
93
+ prev_out = prev.get("output_hash", "")
94
+
95
+ if (
96
+ not force
97
+ and out_path.is_file()
98
+ and prev_src == src_hash
99
+ and prev_out
100
+ and sha256_bytes(out_path.read_bytes()) == prev_out
101
+ ):
102
+ summary["skip"] += 1
103
+ new_entries[key] = {"sources_hash": src_hash, "output_hash": prev_out}
104
+ continue
105
+
106
+ if (
107
+ not force
108
+ and out_path.is_file()
109
+ and prev_src == src_hash
110
+ and prev_out
111
+ and sha256_bytes(out_path.read_bytes()) != prev_out
112
+ ):
113
+ summary["tampered"] += 1
114
+ if not quiet:
115
+ print(f"TAMPERED rules/{each_mapping.output_name} (manual edit; regenerating)")
116
+
117
+ summary["update"] += 1
118
+ if not quiet:
119
+ print(f"UPDATE rules/{each_mapping.output_name}")
120
+
121
+ body = apply_transform(
122
+ each_mapping.transform,
123
+ each_mapping.sources,
124
+ strip_leading_frontmatter=each_mapping.strip_leading_frontmatter,
125
+ )
126
+ full = _full_mdc(each_mapping, body)
127
+ out_hash = sha256_bytes(full.encode("utf-8"))
128
+ new_entries[key] = {"sources_hash": src_hash, "output_hash": out_hash}
129
+
130
+ if write_allowed:
131
+ out_path.write_text(full, encoding="utf-8", newline="\n")
132
+
133
+ expected = {each_mapping.output_name for each_mapping in mappings}
134
+ for each_path in out_dir.glob("*.mdc"):
135
+ if each_path.name not in expected:
136
+ summary["orphan"] += 1
137
+ if not quiet:
138
+ print(f"WARN {each_path.relative_to(cursor)} (orphan — not generated by this tool)")
139
+
140
+ return summary, new_entries
141
+
142
+
143
+ def run(argv: list[str] | None = None) -> int:
144
+ argument_parser = argparse.ArgumentParser(description="Sync Claude rules to Cursor .mdc files")
145
+ argument_parser.add_argument("--force", action="store_true", help="Regenerate all outputs")
146
+ argument_parser.add_argument("--dry-run", action="store_true", help="Print actions only")
147
+ argument_parser.add_argument("--check", action="store_true", help="Exit 1 if anything stale")
148
+ argument_parser.add_argument("--quiet", action="store_true", help="Minimal output when up to date")
149
+ args = argument_parser.parse_args(argv)
150
+
151
+ claude, cursor, out_dir, manifest_path = llm_layout_paths()
152
+ mappings = build_mappings(claude)
153
+ old_manifest = _load_manifest(manifest_path)
154
+ entries_meta: dict = old_manifest.get("entries", {})
155
+ docs_entries_meta: dict = old_manifest.get("docs_entries", {})
156
+
157
+ if args.check:
158
+ return _run_check(mappings, out_dir, entries_meta, claude, cursor, docs_entries_meta)
159
+
160
+ if not args.dry_run:
161
+ out_dir.mkdir(parents=True, exist_ok=True)
162
+ new_docs_entries = sync_canonical_docs(claude, cursor, args.dry_run, args.quiet)
163
+
164
+ summary, new_entries = _sync_rules(
165
+ mappings,
166
+ out_dir,
167
+ entries_meta,
168
+ force=args.force,
169
+ dry_run=args.dry_run,
170
+ quiet=args.quiet,
171
+ cursor=cursor,
172
+ )
173
+
174
+ manifest_out = {
175
+ "generated_at": datetime.now(timezone.utc).isoformat(),
176
+ "generator_version": GENERATOR_VERSION,
177
+ "entries": new_entries,
178
+ "docs_entries": new_docs_entries,
179
+ }
180
+ if not args.dry_run:
181
+ manifest_path.write_text(json.dumps(manifest_out, indent=2) + "\n", encoding="utf-8")
182
+
183
+ if (
184
+ not args.quiet
185
+ and summary["skip"]
186
+ and not any((summary["update"], summary["tampered"], summary["warn"], summary["orphan"]))
187
+ ):
188
+ print(f"SKIP all {summary['skip']} rule(s) unchanged")
189
+
190
+ return 0
191
+
192
+
193
+ def main() -> int:
194
+ return run(sys.argv[1:])
@@ -0,0 +1,7 @@
1
+ """Content hashing for manifest entries."""
2
+
3
+ import hashlib
4
+
5
+
6
+ def sha256_bytes(content_bytes: bytes) -> str:
7
+ return hashlib.sha256(content_bytes).hexdigest()
@@ -0,0 +1,18 @@
1
+ """Resolve Claude / Cursor layout paths (LLM_SETTINGS_ROOT or home)."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+
7
+ def llm_layout_paths() -> tuple[Path, Path, Path, Path]:
8
+ """Return (claude_dir, cursor_dir, rules_out_dir, manifest_path)."""
9
+ raw = os.environ.get("LLM_SETTINGS_ROOT", "").strip()
10
+ if raw:
11
+ base = Path(raw).expanduser().resolve()
12
+ claude = base / ".claude"
13
+ cursor = base / ".cursor"
14
+ else:
15
+ home = Path.home()
16
+ claude = home / ".claude"
17
+ cursor = home / ".cursor"
18
+ return claude, cursor, cursor / "rules", cursor / ".sync-manifest.json"
@@ -0,0 +1,321 @@
1
+ """Build Cursor .mdc bodies from Claude rules and docs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from sync_to_cursor.config import MAX_RULE_BODY_LINES
11
+
12
+
13
+ def _parse_h2_sections(markdown: str) -> dict[str, str]:
14
+ parts = re.split(r"^## ", markdown, flags=re.MULTILINE)
15
+ sections: dict[str, str] = {}
16
+ for part in parts[1:]:
17
+ title_line, _, body = part.partition("\n")
18
+ sections[title_line.strip()] = body.strip()
19
+ return sections
20
+
21
+
22
+ def _filter_core_principles(body: str) -> str:
23
+ lines = []
24
+ for line in body.splitlines():
25
+ if "readability-review" in line or "readability standard" in line:
26
+ continue
27
+ lines.append(line)
28
+ return "\n".join(lines).strip()
29
+
30
+
31
+ def _limit_lines(text: str, max_lines: int) -> str:
32
+ lines = text.splitlines()
33
+ if len(lines) <= max_lines:
34
+ return text
35
+ return (
36
+ "\n".join(lines[:max_lines])
37
+ + "\n\n_(Truncated for Cursor rule length; see the synced reference in `.cursor/docs/` "
38
+ "(`CODE_RULES.md` or `TEST_QUALITY.md` as applicable) and follow its canonical source "
39
+ "under `~/.claude/system-prompts/software-engineer.xml` when needed.)_"
40
+ )
41
+
42
+
43
+ def _strip_code_standards_blockquote(markdown: str) -> str:
44
+ lines = markdown.splitlines()
45
+ out: list[str] = []
46
+ i = 0
47
+ while i < len(lines):
48
+ if lines[i].startswith(">") and "MANDATORY REFERENCE" in lines[i]:
49
+ while i < len(lines) and lines[i].startswith(">"):
50
+ i += 1
51
+ while i < len(lines) and lines[i].strip() == "":
52
+ i += 1
53
+ continue
54
+ out.append(lines[i])
55
+ i += 1
56
+ return "\n".join(out).strip()
57
+
58
+
59
+ def merge_code_standards(sources: tuple[Path, ...]) -> str:
60
+ code_standards_markdown = _strip_code_standards_blockquote(
61
+ sources[0].read_text(encoding="utf-8")
62
+ )
63
+ code_standards_markdown = "\n".join(
64
+ line for line in code_standards_markdown.splitlines() if not line.strip().startswith("- TDD ")
65
+ )
66
+ code_rules_markdown = sources[1].read_text(encoding="utf-8")
67
+ sections_by_heading = _parse_h2_sections(code_rules_markdown)
68
+ if not sections_by_heading:
69
+ pointer_fallback_note = (
70
+ "_(Full code-quality rules: `~/.claude/system-prompts/software-engineer.xml`"
71
+ " under `<code_quality>`.)_"
72
+ )
73
+ chunks = [code_standards_markdown, "", pointer_fallback_note]
74
+ return _limit_lines("\n\n".join(chunks), MAX_RULE_BODY_LINES)
75
+ include_order = [
76
+ "COMMENT PRESERVATION (ABSOLUTE RULE)",
77
+ "CORE PRINCIPLES",
78
+ "⚡ HOOK-ENFORCED RULES",
79
+ "4. CONFIG LOCATIONS",
80
+ "5. NO ABBREVIATIONS",
81
+ "6. COMPLETE TYPE HINTS",
82
+ "9. SELF-CONTAINED COMPONENTS",
83
+ ]
84
+ chunks = [
85
+ code_standards_markdown,
86
+ "",
87
+ "## Reference (full text: `.cursor/docs/CODE_RULES.md`)",
88
+ ]
89
+ for title in include_order:
90
+ body = sections_by_heading.get(title, "")
91
+ if title == "CORE PRINCIPLES":
92
+ body = _filter_core_principles(body)
93
+ if body:
94
+ chunks.append(f"## {title}\n\n{body}")
95
+ merged = "\n\n".join(chunks)
96
+ return _limit_lines(merged, MAX_RULE_BODY_LINES)
97
+
98
+
99
+ def merge_test_quality(sources: tuple[Path, ...]) -> str:
100
+ testing = sources[0].read_text(encoding="utf-8").strip()
101
+ test_quality_markdown = sources[1].read_text(encoding="utf-8")
102
+ sections_by_heading = _parse_h2_sections(test_quality_markdown)
103
+ include_order = [
104
+ "Delete Useless Tests",
105
+ "Test Dependencies MUST FAIL",
106
+ "Core Testing Principles",
107
+ "React Testing Patterns",
108
+ "Test File Organization",
109
+ ]
110
+ chunks = [testing, "", "## Reference (full text: `.cursor/docs/TEST_QUALITY.md`)"]
111
+ for title in include_order:
112
+ body = sections_by_heading.get(title, "")
113
+ if body:
114
+ chunks.append(f"## {title}\n\n{body}")
115
+ merged = "\n\n".join(chunks)
116
+ return _limit_lines(merged, MAX_RULE_BODY_LINES)
117
+
118
+
119
+ def strip_anthropic_refs(text: str) -> str:
120
+ out_lines: list[str] = []
121
+ for line in text.splitlines():
122
+ s = line.strip()
123
+ if s.startswith("Source:") and (
124
+ "anthropic" in s.lower()
125
+ or "claude.com" in s.lower()
126
+ or "docs.anthropic" in s.lower()
127
+ ):
128
+ continue
129
+ if "docs.anthropic.com" in line:
130
+ continue
131
+ out_lines.append(line)
132
+ text = "\n".join(out_lines)
133
+ text = re.sub(
134
+ r"<do_not_act_before_instructions>\s*",
135
+ "",
136
+ text,
137
+ flags=re.DOTALL,
138
+ )
139
+ text = re.sub(r"\s*</do_not_act_before_instructions>", "", text)
140
+ return text.strip()
141
+
142
+
143
+ def verbatim(text: str) -> str:
144
+ return text.strip()
145
+
146
+
147
+ def near_verbatim(text: str) -> str:
148
+ return strip_anthropic_refs(text)
149
+
150
+
151
+ def strip_leading_yaml_frontmatter(text: str) -> str:
152
+ """Remove leading `---` ... `---` block (e.g. Claude `paths:`) so Cursor `.mdc` uses its own frontmatter."""
153
+ lines = text.splitlines()
154
+ if not lines or lines[0].strip() != "---":
155
+ return text
156
+ for i in range(1, len(lines)):
157
+ if lines[i].strip() == "---":
158
+ return "\n".join(lines[i + 1 :]).lstrip("\n")
159
+ return text
160
+
161
+
162
+ TransformName = Literal["verbatim", "near_verbatim", "merge_code_standards", "merge_test_quality"]
163
+
164
+
165
+ def apply_transform(
166
+ name: TransformName,
167
+ sources: tuple[Path, ...],
168
+ *,
169
+ strip_leading_frontmatter: bool = False,
170
+ ) -> str:
171
+ if name == "merge_code_standards":
172
+ return merge_code_standards(sources)
173
+ if name == "merge_test_quality":
174
+ return merge_test_quality(sources)
175
+ raw = "\n\n".join(p.read_text(encoding="utf-8") for p in sources)
176
+ if strip_leading_frontmatter:
177
+ raw = strip_leading_yaml_frontmatter(raw)
178
+ if name == "verbatim":
179
+ return verbatim(raw)
180
+ if name == "near_verbatim":
181
+ return near_verbatim(raw)
182
+ raise AssertionError(name)
183
+
184
+
185
+ @dataclass(frozen=True)
186
+ class RuleMapping:
187
+ key: str
188
+ sources: tuple[Path, ...]
189
+ output_name: str
190
+ always_apply: bool
191
+ globs: str | None
192
+ description: str
193
+ transform: TransformName
194
+ strip_leading_frontmatter: bool = False
195
+
196
+
197
+ def _frontmatter(description: str, always_apply: bool, globs: str | None) -> str:
198
+ escaped_description = description.replace('"', '\\"')
199
+ lines = ["---", f'description: "{escaped_description}"']
200
+ if globs:
201
+ escaped_globs = globs.replace('"', '\\"')
202
+ lines.append(f'globs: "{escaped_globs}"')
203
+ lines.append(f"alwaysApply: {'true' if always_apply else 'false'}")
204
+ lines.append("---")
205
+ return "\n".join(lines) + "\n"
206
+
207
+
208
+ def _full_mdc(mapping: RuleMapping, body: str) -> str:
209
+ generated_header = (
210
+ "<!-- Generated by sync-to-cursor.py — do not edit directly -->\n"
211
+ "<!-- Re-run: python ~/.claude/scripts/sync-to-cursor.py -->\n"
212
+ "<!-- Output: .cursor/rules/*.mdc, .cursor/docs/*.md"
213
+ " (see LLM_SETTINGS_ROOT in script docstring) -->\n"
214
+ )
215
+ return _frontmatter(mapping.description, mapping.always_apply, mapping.globs) + "\n" + generated_header + "\n" + body + "\n"
216
+
217
+
218
+ def _read_paths_glob(rule_file: Path) -> str | None:
219
+ """Read `paths:` list from a Claude rule's YAML frontmatter; return as comma-separated Cursor glob string."""
220
+ if not rule_file.is_file():
221
+ return None
222
+ lines = rule_file.read_text(encoding="utf-8").splitlines()
223
+ if not lines or lines[0].strip() != "---":
224
+ return None
225
+ is_in_paths = False
226
+ all_paths: list[str] = []
227
+ for line in lines[1:]:
228
+ if line.strip() == "---":
229
+ break
230
+ if line.startswith("paths:"):
231
+ is_in_paths = True
232
+ continue
233
+ if is_in_paths:
234
+ if line.startswith(" ") or line.startswith("\t"):
235
+ path_value = line.strip().lstrip("-").strip().strip('"').strip("'")
236
+ if path_value:
237
+ all_paths.append(path_value)
238
+ else:
239
+ is_in_paths = False
240
+ return ",".join(all_paths) if all_paths else None
241
+
242
+
243
+ def build_mappings(claude: Path) -> tuple[RuleMapping, ...]:
244
+ rules_directory = claude / "rules"
245
+ docs_directory = claude / "docs"
246
+ test_globs = "**/*.test.*,**/*.spec.*,**/test_*,**/*_test.*"
247
+ return (
248
+ RuleMapping(
249
+ "code-standards",
250
+ (rules_directory / "code-standards.md", docs_directory / "CODE_RULES.md"),
251
+ "code-standards.mdc",
252
+ True,
253
+ None,
254
+ "Core code standards: naming, types, config, hook-enforced rules",
255
+ "merge_code_standards",
256
+ ),
257
+ RuleMapping(
258
+ "tasklings-preferences",
259
+ (rules_directory / "tasklings-preferences.md",),
260
+ "tasklings-preferences.mdc",
261
+ False,
262
+ _read_paths_glob(rules_directory / "tasklings-preferences.md"),
263
+ "Tasklings: Prefer / Do / Always engineering preferences (scoped path)",
264
+ "verbatim",
265
+ True,
266
+ ),
267
+ RuleMapping(
268
+ "right-sized-engineering",
269
+ (rules_directory / "right-sized-engineering.md",),
270
+ "right-sized-engineering.mdc",
271
+ True,
272
+ None,
273
+ "Right-sized engineering and complexity budget",
274
+ "verbatim",
275
+ ),
276
+ RuleMapping(
277
+ "bdd",
278
+ (rules_directory / "bdd.md",),
279
+ "bdd.mdc",
280
+ True,
281
+ None,
282
+ "BDD: discovery-driven protocol (outside-in; Illustrate→Formulate→Automate)",
283
+ "verbatim",
284
+ ),
285
+ RuleMapping(
286
+ "test-quality",
287
+ (rules_directory / "testing.md", docs_directory / "TEST_QUALITY.md"),
288
+ "test-quality.mdc",
289
+ False,
290
+ test_globs,
291
+ "Testing quality for test files",
292
+ "merge_test_quality",
293
+ ),
294
+ RuleMapping(
295
+ "research-mode",
296
+ (rules_directory / "research-mode.md",),
297
+ "research-mode.mdc",
298
+ True,
299
+ None,
300
+ "Research mode: citations and I don't know",
301
+ "near_verbatim",
302
+ ),
303
+ RuleMapping(
304
+ "conservative-action",
305
+ (rules_directory / "conservative-action.md",),
306
+ "conservative-action.mdc",
307
+ True,
308
+ None,
309
+ "Prefer research over action when intent is unclear",
310
+ "near_verbatim",
311
+ ),
312
+ RuleMapping(
313
+ "explore-thoroughly",
314
+ (rules_directory / "explore-thoroughly.md",),
315
+ "explore-thoroughly.mdc",
316
+ True,
317
+ None,
318
+ "Explore codebase before committing to an approach",
319
+ "near_verbatim",
320
+ ),
321
+ )