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.
- package/_shared/advisor/scripts/tier_model_ids.py +6 -2
- package/bin/AGENTS.md +6 -4
- package/bin/install-constants.mjs +51 -0
- package/bin/install.codex-rules.test.mjs +173 -0
- package/bin/install.cursor-rules.test.mjs +103 -0
- package/bin/install.mjs +165 -51
- package/bin/install.profile-root.test.mjs +8 -0
- package/bin/install.prune.test.mjs +2 -1
- package/bin/install.test.mjs +42 -8
- package/bin/install.transaction.test.mjs +1 -0
- package/bin/install.uninstall-transaction.test.mjs +1 -0
- package/bin/resolve-install-root.mjs +43 -10
- package/codex-rules/claude-dev-env.rules +12 -0
- package/package.json +3 -2
- package/scripts/AGENTS.md +0 -7
- package/scripts/claude_chain_runner.py +49 -2
- package/scripts/invoke_code_review.py +48 -51
- package/scripts/resolve_worker_spawn.py +42 -47
- package/scripts/sync_to_cursor/AGENTS.md +3 -3
- package/scripts/sync_to_cursor/canonical_docs.py +11 -11
- package/scripts/sync_to_cursor/config/__init__.py +8 -0
- package/scripts/sync_to_cursor/engine.py +26 -1
- package/scripts/sync_to_cursor/rules.py +76 -5
- package/scripts/test_claude_chain_runner.py +28 -0
- package/scripts/test_dispatcher_profile_import.py +184 -0
- package/scripts/test_resolve_worker_spawn.py +119 -1
- package/scripts/test_validate_instruction_pairs.py +30 -0
- package/scripts/tests/AGENTS.md +2 -0
- package/scripts/tests/test_engine.py +102 -0
- package/scripts/tests/test_rules.py +79 -0
- package/scripts/profile-isolation-launchers/config/mcp-bundles.json +0 -25
- package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +0 -60
- package/scripts/profile-isolation-launchers/config/profiles.manifest.json +0 -54
- package/scripts/profile-isolation-launchers/config/shared-allowlist.json +0 -64
- package/scripts/profile-isolation-launchers/launcher-runtime.mjs +0 -180
- package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +0 -288
- package/scripts/profile-isolation-launchers/mcp-bundles.mjs +0 -275
- package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +0 -221
- package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +0 -108
- package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +0 -147
- package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +0 -102
- package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +0 -210
- package/scripts/profile-isolation-launchers/version-compatibility.mjs +0 -299
- package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +0 -127
- package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +0 -51
- package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +0 -77
- 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
|
-
|
|
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 =
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
45
|
+
_scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
|
|
41
46
|
)
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
44
|
+
_scripts_directory_path.parent / "_shared" / "advisor" / "scripts"
|
|
40
45
|
)
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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
|
|
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
|
|
21
|
-
src = claude / "docs" /
|
|
22
|
-
dst = docs_out /
|
|
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/{
|
|
28
|
+
print(f"WARN docs/{each_name} (source removed — deleted stale copy at {dst})")
|
|
29
29
|
elif not quiet:
|
|
30
|
-
print(f"WARN docs/{
|
|
30
|
+
print(f"WARN docs/{each_name} (missing source: {src})")
|
|
31
31
|
continue
|
|
32
|
-
key = f"docs/{
|
|
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
|
|
48
|
-
key = f"docs/{
|
|
49
|
-
src = claude / "docs" /
|
|
50
|
-
dst = cursor / "docs" /
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|