claude-dev-env 2.14.1 → 2.15.1

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 (47) hide show
  1. package/_shared/advisor/scripts/tier_model_ids.py +6 -2
  2. package/bin/AGENTS.md +6 -4
  3. package/bin/install-constants.mjs +51 -0
  4. package/bin/install.codex-rules.test.mjs +173 -0
  5. package/bin/install.cursor-rules.test.mjs +103 -0
  6. package/bin/install.mjs +165 -51
  7. package/bin/install.profile-root.test.mjs +8 -0
  8. package/bin/install.prune.test.mjs +2 -1
  9. package/bin/install.test.mjs +42 -8
  10. package/bin/install.transaction.test.mjs +1 -0
  11. package/bin/install.uninstall-transaction.test.mjs +1 -0
  12. package/bin/resolve-install-root.mjs +43 -10
  13. package/codex-rules/claude-dev-env.rules +12 -0
  14. package/package.json +3 -2
  15. package/scripts/AGENTS.md +0 -7
  16. package/scripts/claude_chain_runner.py +49 -2
  17. package/scripts/invoke_code_review.py +48 -51
  18. package/scripts/resolve_worker_spawn.py +42 -47
  19. package/scripts/sync_to_cursor/AGENTS.md +3 -3
  20. package/scripts/sync_to_cursor/canonical_docs.py +11 -11
  21. package/scripts/sync_to_cursor/config/__init__.py +8 -0
  22. package/scripts/sync_to_cursor/engine.py +26 -1
  23. package/scripts/sync_to_cursor/rules.py +76 -5
  24. package/scripts/test_claude_chain_runner.py +28 -0
  25. package/scripts/test_dispatcher_profile_import.py +184 -0
  26. package/scripts/test_resolve_worker_spawn.py +119 -1
  27. package/scripts/test_validate_instruction_pairs.py +30 -0
  28. package/scripts/tests/AGENTS.md +2 -0
  29. package/scripts/tests/test_engine.py +102 -0
  30. package/scripts/tests/test_rules.py +79 -0
  31. package/scripts/profile-isolation-launchers/config/mcp-bundles.json +0 -25
  32. package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +0 -60
  33. package/scripts/profile-isolation-launchers/config/profiles.manifest.json +0 -54
  34. package/scripts/profile-isolation-launchers/config/shared-allowlist.json +0 -64
  35. package/scripts/profile-isolation-launchers/launcher-runtime.mjs +0 -180
  36. package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +0 -288
  37. package/scripts/profile-isolation-launchers/mcp-bundles.mjs +0 -275
  38. package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +0 -221
  39. package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +0 -108
  40. package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +0 -147
  41. package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +0 -102
  42. package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +0 -210
  43. package/scripts/profile-isolation-launchers/version-compatibility.mjs +0 -299
  44. package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +0 -127
  45. package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +0 -51
  46. package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +0 -77
  47. package/scripts/sync_to_cursor/config.py +0 -5
package/scripts/AGENTS.md CHANGED
@@ -37,7 +37,6 @@ Utility scripts installed into `~/.claude/scripts/` by `bin/install.mjs`. Each s
37
37
  |---|---|
38
38
  | `ci/` | CI-only adapters; `windows-installer-lifecycle.ps1` runs the Node installer 16-check driver under isolated HOME/USERPROFILE/GIT_CONFIG_GLOBAL and writes bounded evidence |
39
39
  | `dev_env_scripts_constants/` | Named constants (`timing.py`, `grok_worker_constants.py`, …) for scripts in this directory, including worker-advisor placeholder launcher/model/effort, four verdict signals, correction cap, and advisor timeout |
40
- | `profile-isolation-launchers/` | Profile-launcher contract plus MCP activation, Windows shortcut source semantics, and version preflight: profiles manifest, shared-path allowlist, profile resolver, `CLAUDE_CONFIG_DIR` authority, lean/full MCP bundle materialization into profile `mcp.json`, pure CLI/Desktop version-compatibility classifier consumed by launcher preflight, read-only shortcut inventory and preview reconcile under `windows/`. Live launcher execution, provenance pin, live shortcut mutation, and L1 deploy stay residual |
41
40
  | `sync_to_cursor/` | Package that builds Cursor `.mdc` files from Claude rules and docs |
42
41
  | `tests/` | pytest suite for the Python scripts and Pester (`*.Tests.ps1`) suite for the PowerShell scripts in this directory |
43
42
 
@@ -49,12 +48,6 @@ Python scripts (pytest):
49
48
  python -m pytest packages/claude-dev-env/scripts/tests/
50
49
  ```
51
50
 
52
- Profile-isolation launcher contract (node:test):
53
-
54
- ```bash
55
- node --test packages/claude-dev-env/scripts/profile-isolation-launchers/**/*.test.mjs
56
- ```
57
-
58
51
  PowerShell scripts (Pester 5+, `*.Tests.ps1`):
59
52
 
60
53
  ```powershell
@@ -48,7 +48,9 @@ import os
48
48
  import subprocess
49
49
  import sys
50
50
  import tempfile
51
- from collections.abc import Callable, Sequence
51
+ import threading
52
+ from collections.abc import Callable, Iterator, Sequence
53
+ from contextlib import contextmanager
52
54
  from dataclasses import dataclass, field
53
55
  from pathlib import Path
54
56
  from types import ModuleType
@@ -327,6 +329,35 @@ class WeeklyUsageAccountReport(Protocol):
327
329
 
328
330
 
329
331
  chain_subprocess_runner = _run_captured_subprocess
332
+ _shared_chain_subprocess_lock = threading.Lock()
333
+
334
+
335
+ def chain_subprocess_runner_lock() -> threading.Lock:
336
+ """Return the lock for adapters that temporarily configure the runner."""
337
+ return _shared_chain_subprocess_lock
338
+
339
+
340
+ @contextmanager
341
+ def override_chain_subprocess_runner(
342
+ replacement_runner: Callable[..., subprocess.CompletedProcess[str]],
343
+ ) -> Iterator[Callable[..., subprocess.CompletedProcess[str]]]:
344
+ """Temporarily replace the chain subprocess seam under its shared lock.
345
+
346
+ Args:
347
+ replacement_runner: Callable used for subprocess invocations during the
348
+ context.
349
+
350
+ Yields:
351
+ The runner that was active before the replacement.
352
+ """
353
+ global chain_subprocess_runner
354
+ with chain_subprocess_runner_lock():
355
+ previous_runner = chain_subprocess_runner
356
+ chain_subprocess_runner = replacement_runner
357
+ try:
358
+ yield previous_runner
359
+ finally:
360
+ chain_subprocess_runner = previous_runner
330
361
 
331
362
 
332
363
  def _load_chain_usage_module() -> ModuleType:
@@ -1138,6 +1169,22 @@ def _persist_served_affinity(
1138
1169
  return
1139
1170
 
1140
1171
 
1172
+ def _run_cli_chain(
1173
+ all_claude_arguments: list[str],
1174
+ *,
1175
+ timeout_seconds: int,
1176
+ stdin_text: str | None,
1177
+ routing_mode: str,
1178
+ ) -> ChainInvocationOutcome:
1179
+ """Run the CLI-selected chain arguments through the public runner."""
1180
+ return run_claude(
1181
+ all_claude_arguments,
1182
+ timeout_seconds=timeout_seconds,
1183
+ stdin_text=stdin_text,
1184
+ routing_mode=routing_mode,
1185
+ )
1186
+
1187
+
1141
1188
  def _build_argument_parser() -> argparse.ArgumentParser:
1142
1189
  parser = argparse.ArgumentParser(
1143
1190
  description="Run a claude invocation through the fallback chain."
@@ -1206,7 +1253,7 @@ def main(all_command_arguments: list[str]) -> int:
1206
1253
  all_claude_arguments = _strip_leading_separator(parsed_arguments.passthrough)
1207
1254
  maybe_stdin_text = _read_piped_stdin_text()
1208
1255
  try:
1209
- chain_outcome = run_claude(
1256
+ chain_outcome = _run_cli_chain(
1210
1257
  all_claude_arguments,
1211
1258
  timeout_seconds=parsed_arguments.timeout_seconds,
1212
1259
  stdin_text=maybe_stdin_text,
@@ -28,20 +28,30 @@ import argparse
28
28
  import json
29
29
  import subprocess
30
30
  import sys
31
- import threading
32
31
  from collections.abc import Callable, Sequence
33
32
  from dataclasses import dataclass
34
33
  from pathlib import Path
35
34
 
36
- if str(Path(__file__).resolve().parent) not in sys.path:
37
- sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+ _scripts_directory_path = Path(__file__).resolve().parent
36
+ _scripts_directory = str(_scripts_directory_path)
37
+ sys.path[:] = [
38
+ each_existing_entry
39
+ for each_existing_entry in sys.path
40
+ if each_existing_entry != _scripts_directory
41
+ ]
42
+ sys.path[:0] = [_scripts_directory]
38
43
 
39
44
  _advisor_scripts_path = str(
40
- Path(__file__).resolve().parent.parent / "_shared" / "advisor" / "scripts"
45
+ _scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
41
46
  )
42
- if _advisor_scripts_path not in sys.path:
43
- sys.path.insert(0, _advisor_scripts_path)
47
+ sys.path[:] = [
48
+ each_existing_entry
49
+ for each_existing_entry in sys.path
50
+ if each_existing_entry != _advisor_scripts_path
51
+ ]
52
+ sys.path[:0] = [_advisor_scripts_path]
44
53
 
54
+ from tier_model_ids import detect_host_profile # noqa: E402
45
55
  from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
46
56
  HOST_PROFILE_CLAUDE,
47
57
  )
@@ -93,14 +103,6 @@ from dev_env_scripts_constants.grok_worker_constants import ( # noqa: E402
93
103
  from dev_env_scripts_constants.timing import ( # noqa: E402
94
104
  DEFAULT_CODE_REVIEW_TIMEOUT_SECONDS,
95
105
  )
96
- from tier_model_ids import detect_host_profile # noqa: E402
97
-
98
- _CHAIN_RUNNER_LOCK = threading.Lock()
99
-
100
-
101
- def _chain_runner_lock() -> threading.Lock:
102
- """Return the module lock that serializes chain-runner stdin/cwd swaps."""
103
- return _CHAIN_RUNNER_LOCK
104
106
 
105
107
 
106
108
  @dataclass(frozen=True)
@@ -302,44 +304,39 @@ def _run_claude_with_empty_stdin(
302
304
  working_directory: Path,
303
305
  ) -> ChainInvocationOutcome:
304
306
  working_directory_path = str(working_directory)
305
- with _CHAIN_RUNNER_LOCK:
306
- previous_runner: TextCapturingSubprocessRunner = (
307
- chain_runner.chain_subprocess_runner
308
- )
309
307
 
310
- def _runner_with_empty_stdin(
311
- all_invocation_tokens: Sequence[str],
312
- *all_positionals: object,
313
- **all_keywords: object,
314
- ) -> subprocess.CompletedProcess[str]:
315
- del all_positionals
316
- maybe_timeout = all_keywords.get("timeout")
317
- timeout_for_run: float | None
318
- if isinstance(maybe_timeout, (int, float)):
319
- timeout_for_run = float(maybe_timeout)
320
- else:
321
- timeout_for_run = None
322
- forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
323
- completed_process: subprocess.CompletedProcess[str] = previous_runner(
324
- all_invocation_tokens,
325
- capture_output=True,
326
- text=True,
327
- timeout=timeout_for_run,
328
- check=False,
329
- stdin=subprocess.DEVNULL,
330
- cwd=working_directory_path,
331
- **forwarded_text_codec,
332
- )
333
- return completed_process
334
-
335
- empty_stdin_runner: TextCapturingSubprocessRunner = _runner_with_empty_stdin
336
- setattr(chain_runner, "chain_subprocess_runner", empty_stdin_runner)
337
- try:
338
- return review_claude_runner(
339
- all_claude_arguments, timeout_seconds=timeout_seconds
340
- )
341
- finally:
342
- setattr(chain_runner, "chain_subprocess_runner", previous_runner)
308
+ def _runner_with_empty_stdin(
309
+ all_invocation_tokens: Sequence[str],
310
+ *all_positionals: object,
311
+ **all_keywords: object,
312
+ ) -> subprocess.CompletedProcess[str]:
313
+ del all_positionals
314
+ maybe_timeout = all_keywords.get("timeout")
315
+ timeout_for_run: float | None
316
+ if isinstance(maybe_timeout, (int, float)):
317
+ timeout_for_run = float(maybe_timeout)
318
+ else:
319
+ timeout_for_run = None
320
+ forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
321
+ completed_process: subprocess.CompletedProcess[str] = previous_runner(
322
+ all_invocation_tokens,
323
+ capture_output=True,
324
+ text=True,
325
+ timeout=timeout_for_run,
326
+ check=False,
327
+ stdin=subprocess.DEVNULL,
328
+ cwd=working_directory_path,
329
+ **forwarded_text_codec,
330
+ )
331
+ return completed_process
332
+
333
+ empty_stdin_runner: TextCapturingSubprocessRunner = _runner_with_empty_stdin
334
+ with chain_runner.override_chain_subprocess_runner(
335
+ empty_stdin_runner
336
+ ) as previous_runner:
337
+ return review_claude_runner(
338
+ all_claude_arguments, timeout_seconds=timeout_seconds
339
+ )
343
340
 
344
341
 
345
342
  def _in_session_outcome() -> CodeReviewOutcome:
@@ -26,21 +26,31 @@ import argparse
26
26
  import json
27
27
  import subprocess
28
28
  import sys
29
- import threading
30
29
  from collections.abc import Callable, Sequence
31
30
  from dataclasses import dataclass
32
31
  from pathlib import Path
33
32
  from typing import IO
34
33
 
35
- if str(Path(__file__).resolve().parent) not in sys.path:
36
- sys.path.insert(0, str(Path(__file__).resolve().parent))
34
+ _scripts_directory_path = Path(__file__).resolve().parent
35
+ _scripts_directory = str(_scripts_directory_path)
36
+ sys.path[:] = [
37
+ each_existing_entry
38
+ for each_existing_entry in sys.path
39
+ if each_existing_entry != _scripts_directory
40
+ ]
41
+ sys.path[:0] = [_scripts_directory]
37
42
 
38
43
  _advisor_scripts_path = str(
39
- Path(__file__).resolve().parent.parent / "_shared" / "advisor" / "scripts"
44
+ _scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
40
45
  )
41
- if _advisor_scripts_path not in sys.path:
42
- sys.path.insert(0, _advisor_scripts_path)
46
+ sys.path[:] = [
47
+ each_existing_entry
48
+ for each_existing_entry in sys.path
49
+ if each_existing_entry != _advisor_scripts_path
50
+ ]
51
+ sys.path[:0] = [_advisor_scripts_path]
43
52
 
53
+ from tier_model_ids import detect_host_profile # noqa: E402
44
54
  from advisor_scripts_constants.model_tier_run_validator_constants import ( # noqa: E402
45
55
  HOST_PROFILE_CLAUDE,
46
56
  )
@@ -94,14 +104,6 @@ from grok_headless_runner import ( # noqa: E402
94
104
  run_headless_worker,
95
105
  )
96
106
  from grok_worker_preflight import PreflightOutcome, run_preflight # noqa: E402
97
- from tier_model_ids import detect_host_profile # noqa: E402
98
-
99
- _HEADLESS_CHAIN_RUNNER_LOCK = threading.Lock()
100
-
101
-
102
- def _headless_chain_runner_lock() -> threading.Lock:
103
- """Return the module lock that serializes headless chain-runner swaps."""
104
- return _HEADLESS_CHAIN_RUNNER_LOCK
105
107
 
106
108
 
107
109
  @dataclass(frozen=True)
@@ -184,41 +186,34 @@ def _run_claude_with_headless_overrides(
184
186
  prompt_stdin: IO[str],
185
187
  ) -> ChainInvocationOutcome:
186
188
  working_directory_path = str(working_directory)
187
- with _HEADLESS_CHAIN_RUNNER_LOCK:
188
- previous_runner: TextCapturingSubprocessRunner = (
189
- chain_runner.chain_subprocess_runner
190
- )
191
189
 
192
- def _runner_with_headless_overrides(
193
- all_invocation_tokens: Sequence[str],
194
- *all_positionals: object,
195
- **all_keywords: object,
196
- ) -> subprocess.CompletedProcess[str]:
197
- del all_positionals
198
- prompt_stdin.seek(0)
199
- forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
200
- completed_process: subprocess.CompletedProcess[str] = previous_runner(
201
- all_invocation_tokens,
202
- capture_output=True,
203
- text=True,
204
- timeout=_timeout_seconds_from_keywords(all_keywords),
205
- check=False,
206
- stdin=prompt_stdin,
207
- cwd=working_directory_path,
208
- **forwarded_text_codec,
209
- )
210
- return completed_process
211
-
212
- headless_runner: TextCapturingSubprocessRunner = (
213
- _runner_with_headless_overrides
190
+ def _runner_with_headless_overrides(
191
+ all_invocation_tokens: Sequence[str],
192
+ *all_positionals: object,
193
+ **all_keywords: object,
194
+ ) -> subprocess.CompletedProcess[str]:
195
+ del all_positionals
196
+ prompt_stdin.seek(0)
197
+ forwarded_text_codec = collect_forwarded_text_codec(all_keywords)
198
+ completed_process: subprocess.CompletedProcess[str] = previous_runner(
199
+ all_invocation_tokens,
200
+ capture_output=True,
201
+ text=True,
202
+ timeout=_timeout_seconds_from_keywords(all_keywords),
203
+ check=False,
204
+ stdin=prompt_stdin,
205
+ cwd=working_directory_path,
206
+ **forwarded_text_codec,
207
+ )
208
+ return completed_process
209
+
210
+ headless_runner: TextCapturingSubprocessRunner = _runner_with_headless_overrides
211
+ with chain_runner.override_chain_subprocess_runner(
212
+ headless_runner
213
+ ) as previous_runner:
214
+ return spawn_claude_runner(
215
+ all_claude_arguments, timeout_seconds=timeout_seconds
214
216
  )
215
- setattr(chain_runner, "chain_subprocess_runner", headless_runner)
216
- try:
217
- return spawn_claude_runner(
218
- all_claude_arguments, timeout_seconds=timeout_seconds
219
- )
220
- finally:
221
- setattr(chain_runner, "chain_subprocess_runner", previous_runner)
222
217
 
223
218
 
224
219
  def _prompt_file_unreadable_outcome(
@@ -6,12 +6,12 @@ Python package that syncs Claude rules and docs to Cursor `.mdc` files. Entry po
6
6
 
7
7
  | File | Purpose |
8
8
  |---|---|
9
- | `engine.py` | Main sync logic: loads the manifest, builds rule mappings, hashes sources, writes `.mdc` files, and updates the manifest |
10
- | `rules.py` | Builds `RuleMapping` objects from Claude rule markdown files; applies transforms to fit Cursor's `.mdc` format |
9
+ | `engine.py` | Main sync logic: loads the manifest, builds rule mappings, hashes sources, writes `.mdc` files, and updates the manifest; `--claude-root` and `--cursor-root` select explicit layouts |
10
+ | `rules.py` | Builds `RuleMapping` objects from Claude rule markdown files; applies transforms to fit Cursor's `.mdc` format; maps remaining `rules/*.md` files to `<stem>.mdc` |
11
11
  | `canonical_docs.py` | Checks and syncs canonical documentation files (`CODE_RULES.md`, `TEST_QUALITY.md`) to the Cursor rules directory |
12
12
  | `paths.py` | Resolves the Claude and Cursor layout paths; respects the `LLM_SETTINGS_ROOT` env var for non-home layouts |
13
13
  | `hashing.py` | SHA-256 helpers that detect whether source files changed since the last sync run |
14
- | `config.py` | Package-level constants: `GENERATOR_VERSION`, `CANONICAL_DOC_FILES`, `MAX_RULE_BODY_LINES` |
14
+ | `config/` | Package-level constants: `GENERATOR_VERSION`, `ALL_CANONICAL_DOC_FILES`, `MAX_RULE_BODY_LINES`, skipped inventory filenames, markdown suffix |
15
15
  | `__init__.py` | Empty package marker |
16
16
 
17
17
  ## Layout resolution
@@ -3,7 +3,7 @@
3
3
  import shutil
4
4
  from pathlib import Path
5
5
 
6
- from sync_to_cursor.config import CANONICAL_DOC_FILES
6
+ from sync_to_cursor.config import ALL_CANONICAL_DOC_FILES
7
7
  from sync_to_cursor.hashing import sha256_bytes
8
8
 
9
9
 
@@ -17,19 +17,19 @@ def sync_canonical_docs(
17
17
  if not dry_run:
18
18
  docs_out.mkdir(parents=True, exist_ok=True)
19
19
  new_docs: dict = {}
20
- for name in CANONICAL_DOC_FILES:
21
- src = claude / "docs" / name
22
- dst = docs_out / name
20
+ for each_name in ALL_CANONICAL_DOC_FILES:
21
+ src = claude / "docs" / each_name
22
+ dst = docs_out / each_name
23
23
  if not src.is_file():
24
24
  if dst.is_file():
25
25
  if not dry_run:
26
26
  dst.unlink()
27
27
  if not quiet:
28
- print(f"WARN docs/{name} (source removed — deleted stale copy at {dst})")
28
+ print(f"WARN docs/{each_name} (source removed — deleted stale copy at {dst})")
29
29
  elif not quiet:
30
- print(f"WARN docs/{name} (missing source: {src})")
30
+ print(f"WARN docs/{each_name} (missing source: {src})")
31
31
  continue
32
- key = f"docs/{name}"
32
+ key = f"docs/{each_name}"
33
33
  src_hash = sha256_bytes(src.read_bytes())
34
34
  if dry_run:
35
35
  if dst.is_file():
@@ -44,10 +44,10 @@ def sync_canonical_docs(
44
44
 
45
45
 
46
46
  def check_canonical_docs(claude: Path, cursor: Path, docs_entries: dict) -> bool:
47
- for name in CANONICAL_DOC_FILES:
48
- key = f"docs/{name}"
49
- src = claude / "docs" / name
50
- dst = cursor / "docs" / name
47
+ for each_name in ALL_CANONICAL_DOC_FILES:
48
+ key = f"docs/{each_name}"
49
+ src = claude / "docs" / each_name
50
+ dst = cursor / "docs" / each_name
51
51
  if not src.is_file():
52
52
  if key in docs_entries:
53
53
  return False
@@ -0,0 +1,8 @@
1
+ """Shared configuration for the sync-to-cursor package."""
2
+
3
+ GENERATOR_VERSION: str = "1.3.0"
4
+ ALL_CANONICAL_DOC_FILES: tuple[str, ...] = ("CODE_RULES.md", "TEST_QUALITY.md")
5
+ MAX_RULE_BODY_LINES: int = 50
6
+ ALL_SKIPPED_RULE_FILE_NAMES: frozenset[str] = frozenset({"CLAUDE.md", "AGENTS.md"})
7
+ MARKDOWN_SUFFIX: str = ".md"
8
+ CLAUDE_RULES_DIRECTORY_NAME: str = "rules"
@@ -140,15 +140,40 @@ def _sync_rules(
140
140
  return summary, new_entries
141
141
 
142
142
 
143
+ def _layout_from_args(args: argparse.Namespace) -> tuple[Path, Path, Path, Path]:
144
+ """Resolve Claude and Cursor roots from flags or the default layout.
145
+
146
+ Args:
147
+ args: Parsed CLI arguments.
148
+
149
+ Returns:
150
+ Claude root, Cursor root, rules output directory, and manifest path.
151
+
152
+ Raises:
153
+ SystemExit: When only one of `--claude-root` / `--cursor-root` is set.
154
+ """
155
+ claude_root = args.claude_root
156
+ cursor_root = args.cursor_root
157
+ if (claude_root is None) != (cursor_root is None):
158
+ raise SystemExit("both --claude-root and --cursor-root are required")
159
+ if claude_root is None:
160
+ return llm_layout_paths()
161
+ claude = Path(claude_root).expanduser().resolve()
162
+ cursor = Path(cursor_root).expanduser().resolve()
163
+ return claude, cursor, cursor / "rules", cursor / ".sync-manifest.json"
164
+
165
+
143
166
  def run(argv: list[str] | None = None) -> int:
144
167
  argument_parser = argparse.ArgumentParser(description="Sync Claude rules to Cursor .mdc files")
145
168
  argument_parser.add_argument("--force", action="store_true", help="Regenerate all outputs")
146
169
  argument_parser.add_argument("--dry-run", action="store_true", help="Print actions only")
147
170
  argument_parser.add_argument("--check", action="store_true", help="Exit 1 if anything stale")
148
171
  argument_parser.add_argument("--quiet", action="store_true", help="Minimal output when up to date")
172
+ argument_parser.add_argument("--claude-root", help="Claude layout root holding rules/ and docs/")
173
+ argument_parser.add_argument("--cursor-root", help="Cursor layout root receiving rules/ and docs/")
149
174
  args = argument_parser.parse_args(argv)
150
175
 
151
- claude, cursor, out_dir, manifest_path = llm_layout_paths()
176
+ claude, cursor, out_dir, manifest_path = _layout_from_args(args)
152
177
  mappings = build_mappings(claude)
153
178
  old_manifest = _load_manifest(manifest_path)
154
179
  entries_meta: dict = old_manifest.get("entries", {})
@@ -7,7 +7,12 @@ from dataclasses import dataclass
7
7
  from pathlib import Path
8
8
  from typing import Literal
9
9
 
10
- from sync_to_cursor.config import MAX_RULE_BODY_LINES
10
+ from sync_to_cursor.config import (
11
+ ALL_SKIPPED_RULE_FILE_NAMES,
12
+ CLAUDE_RULES_DIRECTORY_NAME,
13
+ MARKDOWN_SUFFIX,
14
+ MAX_RULE_BODY_LINES,
15
+ )
11
16
 
12
17
 
13
18
  def _parse_h2_sections(markdown: str) -> dict[str, str]:
@@ -412,6 +417,61 @@ def _path_scoped_mappings(rules_directory: Path, docs_directory: Path) -> tuple[
412
417
  )
413
418
 
414
419
 
420
+ def _description_from_rule_file(rule_file: Path, fallback_key: str) -> str:
421
+ """Return the first markdown heading, or the rule key with spaces.
422
+
423
+ Args:
424
+ rule_file: Claude rule markdown to read.
425
+ fallback_key: Stem used when the file has no heading.
426
+
427
+ Returns:
428
+ A one-line Cursor `description` value.
429
+ """
430
+ for each_line in rule_file.read_text(encoding="utf-8").splitlines():
431
+ stripped = each_line.strip()
432
+ if stripped.startswith("# "):
433
+ return stripped[2:].strip()
434
+ return fallback_key.replace("-", " ")
435
+
436
+
437
+ def _discovered_mappings(
438
+ rules_directory: Path, all_covered_source_names: set[str]
439
+ ) -> tuple[RuleMapping, ...]:
440
+ """Map remaining `*.md` rule files to `<stem>.mdc` Cursor rules.
441
+
442
+ Args:
443
+ rules_directory: Claude `rules/` directory.
444
+ all_covered_source_names: Rule filenames already claimed by curated mappings.
445
+
446
+ Returns:
447
+ One mapping per remaining markdown rule, sorted by filename.
448
+ """
449
+ if not rules_directory.is_dir():
450
+ return ()
451
+ all_discovered: list[RuleMapping] = []
452
+ for each_rule_file in sorted(rules_directory.glob("*.md")):
453
+ if each_rule_file.name in ALL_SKIPPED_RULE_FILE_NAMES:
454
+ continue
455
+ if each_rule_file.name in all_covered_source_names:
456
+ continue
457
+ key = each_rule_file.stem
458
+ paths_glob = _read_paths_glob(each_rule_file)
459
+ has_paths = paths_glob is not None
460
+ all_discovered.append(
461
+ RuleMapping(
462
+ key,
463
+ (each_rule_file,),
464
+ f"{key}.mdc",
465
+ not has_paths,
466
+ paths_glob,
467
+ _description_from_rule_file(each_rule_file, key),
468
+ "verbatim",
469
+ strip_leading_frontmatter=has_paths,
470
+ )
471
+ )
472
+ return tuple(all_discovered)
473
+
474
+
415
475
  def build_mappings(claude: Path) -> tuple[RuleMapping, ...]:
416
476
  """Resolve every rule into a concrete Cursor mapping against a Claude layout.
417
477
 
@@ -420,14 +480,25 @@ def build_mappings(claude: Path) -> tuple[RuleMapping, ...]:
420
480
 
421
481
  Returns:
422
482
  One RuleMapping per rule, each path-scoped rule carrying a glob derived
423
- from its source rule's `paths:` frontmatter.
483
+ from its source rule's `paths:` frontmatter. Curated mappings come first,
484
+ then one generated mapping per remaining `rules/*.md` file.
424
485
  """
425
486
  rules_directory = claude / "rules"
426
487
  docs_directory = claude / "docs"
427
- all_mappings = (
488
+ all_curated = (
428
489
  *_always_apply_mappings(rules_directory, docs_directory),
429
490
  *_path_scoped_mappings(rules_directory, docs_directory),
430
491
  )
431
- mapping_by_key = {each_mapping.key: each_mapping for each_mapping in all_mappings}
492
+ mapping_by_key = {each_mapping.key: each_mapping for each_mapping in all_curated}
432
493
  assert set(mapping_by_key) == set(_merged_mapping_key_order)
433
- return tuple(mapping_by_key[each_key] for each_key in _merged_mapping_key_order)
494
+ ordered_curated = tuple(mapping_by_key[each_key] for each_key in _merged_mapping_key_order)
495
+ all_covered_source_names = {
496
+ each_source.name
497
+ for each_mapping in ordered_curated
498
+ for each_source in each_mapping.sources
499
+ if each_source.suffix == MARKDOWN_SUFFIX
500
+ and each_source.parent.name == CLAUDE_RULES_DIRECTORY_NAME
501
+ }
502
+ return ordered_curated + _discovered_mappings(
503
+ rules_directory, all_covered_source_names
504
+ )
@@ -17,6 +17,8 @@ import claude_chain_runner as runner # noqa: E402
17
17
  import claude_chain_usage as chain_usage # noqa: E402
18
18
  from claude_chain_runner import ( # noqa: E402
19
19
  ChainEntry,
20
+ chain_subprocess_runner_lock,
21
+ override_chain_subprocess_runner,
20
22
  default_affinity_state_path,
21
23
  extract_resume_session_id,
22
24
  load_affinity_store,
@@ -78,6 +80,32 @@ from dev_env_scripts_constants.claude_chain_constants import ( # noqa: E402
78
80
 
79
81
  _LARGE_CAPTURE_BYTE_COUNT = 400_000
80
82
  _LARGE_CAPTURE_MARKER = "X"
83
+
84
+
85
+ def test_chain_subprocess_runner_lock_is_shared() -> None:
86
+ first_lock = chain_subprocess_runner_lock()
87
+ second_lock = chain_subprocess_runner_lock()
88
+
89
+ assert first_lock is second_lock
90
+
91
+
92
+ def test_override_chain_subprocess_runner_restores_previous_runner() -> None:
93
+ previous_runner = runner.chain_subprocess_runner
94
+
95
+ def replacement_runner(
96
+ all_invocation_tokens: list[str],
97
+ **all_keywords: object,
98
+ ) -> subprocess.CompletedProcess[str]:
99
+ del all_invocation_tokens, all_keywords
100
+ return subprocess.CompletedProcess([], 0, "", "")
101
+
102
+ with override_chain_subprocess_runner(replacement_runner) as active_runner:
103
+ assert active_runner is previous_runner
104
+ assert runner.chain_subprocess_runner is replacement_runner
105
+
106
+ assert runner.chain_subprocess_runner is previous_runner
107
+
108
+
81
109
  _STDIN_ECHO_PAYLOAD = "charter body for spool path"
82
110
  _UNDECODABLE_STDOUT_BYTES = b"ok \x90 end"
83
111
  _DECODED_UNDECODABLE_STDOUT = "ok \ufffd end"