claude-dev-env 2.14.1 → 2.15.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.
package/bin/AGENTS.md CHANGED
@@ -1,20 +1,22 @@
1
1
  # bin
2
2
 
3
- The installer and its companion modules. Running `npx claude-dev-env` (or `node bin/install.mjs`) copies package files into the managed root (`~/.claude/` by default; `CLAUDE_CONFIG_DIR` or `--target` selects another), merges hook entries into that root's `settings.json`, installs Git hooks, and writes `~/.mypy.ini` under the process home.
3
+ The installer and its companion modules. Running `npx claude-dev-env` (or `node bin/install.mjs`) copies package files into the managed root (`~/.claude/` by default; `CLAUDE_CONFIG_DIR` or `--target` selects another), merges hook entries into that root's `settings.json`, installs Git hooks, writes `~/.mypy.ini` under the process home, and copies Codex exec-policy files into `~/.codex/rules` (`CODEX_HOME/rules` when that variable is set), and generates Cursor `.mdc` files into `~/.cursor/rules` from the installed Claude rules.
4
4
 
5
5
  ## Files
6
6
 
7
7
  | File | Purpose |
8
8
  |---|---|
9
9
  | `install.mjs` | Main installer: builds a read-only plan via `install-plan.mjs`, then runs mutations inside `install-transaction.mjs` recovery (copy content directories, merge hooks, install skills, prune, git hooks, mypy.ini); routes `CLAUDE_HOME`, the manifest path, and `~/.mypy.ini` through `resolve-install-root.mjs`; resolves single or multi-profile targets before mutation and writes one ownership manifest per target |
10
- | `resolve-install-root.mjs` | Pure install-root resolver: precedence `--target` > `CLAUDE_CONFIG_DIR` > `~/.claude`, separator-boundary containment, and the declared external allowlist for `~/.mypy.ini` |
10
+ | `resolve-install-root.mjs` | Pure install-root resolver: precedence `--target` > `CLAUDE_CONFIG_DIR` > `~/.claude`, separator-boundary containment, and the declared external allowlist for `~/.mypy.ini` plus files under the Codex rules directory and the Cursor home |
11
11
  | `select-install-targets.mjs` | Pure target selection for main-default, explicit `--target`, and `--profile`/`--profiles`; rejects ambiguous or duplicate targets; builds per-target manifest records with `targetIdentity` and `managedRoot` |
12
12
  | `install-plan.mjs` | Read-only install and uninstall plans: install preflight (managed root, source conflicts, Python, settings when hooks install) and uninstall preflight (settings JSON before removal, removable vs skipped manifest records), freezes plans E2/F execute |
13
13
  | `install-transaction.mjs` | Install, update, and uninstall transaction journal: captures prior settings, manifest, managed files, and `core.hooksPath`, restores them on failure, and supports fault injection phases for recovery tests |
14
14
  | `install.transaction.test.mjs` | Unit and sandbox installer tests for snapshot/restore and fault phases (`after_file_staging`, `after_settings_write`, `after_git_config`, `after_manifest_write`) |
15
15
  | `install.uninstall-transaction.test.mjs` | Uninstall plan preflight and recovery: malformed/non-object settings fail before removal, each fault phase restores files/settings/manifest/`core.hooksPath`, retry succeeds, selected-root containment |
16
16
  | `install.profile-root.test.mjs` | Contract tests for the install-root resolver: precedence, containment boundary, external allowlist, and the install.mjs import smoke check |
17
- | `install-constants.mjs` | The named values `install.mjs` reads: `SKIPPED_SOURCE_ENTRY_NAMES` and `SKIPPED_SOURCE_FILE_EXTENSIONS` for the build artifacts the source walk leaves behind, `RUN_BACKUP_DIRECTORY_NAME_PATTERN` for the timestamp shape a run backup directory carries, `MANAGED_SKILLS_DIRECTORY_NAME` and `MANAGED_HOOKS_DIRECTORY_NAME` for the directory name each of those trees carries in a package source and under `~/.claude` read by the copy loops, the hooks.json reads, the git-hook shims, the mypy configuration, and the prunes alike — `SETTINGS_FILE_NAME` for the settings file the merge, the retired-hook prune, and the uninstall purge share, and `MYPY_INI_FILE_NAME` for the home-directory file `install_mypy_ini.mjs` writes (also imported by `resolve-install-root.mjs`) |
17
+ | `install.codex-rules.test.mjs` | Tests that Codex exec-policy files copy to `~/.codex/rules`, honor `CODEX_HOME`, skip `--only journal`, and uninstall without touching `default.rules` |
18
+ | `install.cursor-rules.test.mjs` | Tests that Cursor `.mdc` files generate into `~/.cursor/rules` from Claude rules, skip `--only journal`, and leave a local extra `.mdc` in place |
19
+ | `install-constants.mjs` | The named values `install.mjs` reads: `SKIPPED_SOURCE_ENTRY_NAMES` and `SKIPPED_SOURCE_FILE_EXTENSIONS` for the build artifacts the source walk leaves behind, `RUN_BACKUP_DIRECTORY_NAME_PATTERN` for the timestamp shape a run backup directory carries, `MANAGED_SKILLS_DIRECTORY_NAME` and `MANAGED_HOOKS_DIRECTORY_NAME` for the directory name each of those trees carries in a package source and under `~/.claude` — read by the copy loops, the hooks.json reads, the git-hook shims, the mypy configuration, and the prunes alike — `SETTINGS_FILE_NAME` for the settings file the merge, the retired-hook prune, and the uninstall purge share, and `MYPY_INI_FILE_NAME` for the home-directory file `install_mypy_ini.mjs` writes, plus the Codex home and rules directory names `resolve-install-root.mjs` uses |
18
20
  | `ever-shipped-skills.mjs` | Static `EVER_SHIPPED_SKILL_NAMES` set of every top-level skill directory name the package has shipped; the installer subtracts the current skill set from it to prune retired skills left under `~/.claude/skills` |
19
21
  | `expand_home_directory_tokens.mjs` | Expands residual `$HOME` / `${HOME}` / `~/` tokens in settings.json hook and statusLine commands to absolute home paths at install time (literal-safe for homes that contain `$`) |
20
22
  | `git_hooks_installer.mjs` | Installs or updates the `pre-commit`, `pre-push`, and `post-commit` Git hooks in the user's git config; writes hook scripts that delegate to the installed Python hooks |
@@ -78,7 +80,7 @@ A run that moves nothing sweeps nothing, so every recovery point the user holds
78
80
 
79
81
  `--uninstall` builds a read-only uninstall plan, captures a recovery snapshot, then removes each file the plan lists.
80
82
 
81
- Settings JSON is validated before any removal. A malformed or non-object `settings.json` fails closed with the managed files still on disk. Each manifest record passes a containment guard: the path resolves under `~/.claude`, or it names the `~/.mypy.ini` the install writes in the home directory. Every other record is skipped with a warning and counted. Skipping keeps one malformed record — hand-edited, or written by an installer that ran against a different home — from stranding the user with a half-removed install. The purge removes every legitimate record, clears the manifest, and reports the skipped count.
83
+ Settings JSON is validated before any removal. A malformed or non-object `settings.json` fails closed with the managed files still on disk. Each manifest record passes a containment guard: the path resolves under `~/.claude`, or it names the `~/.mypy.ini` the install writes in the home directory, or it sits under the Codex rules directory. Every other record is skipped with a warning and counted. Skipping keeps one malformed record — hand-edited, or written by an installer that ran against a different home — from stranding the user with a half-removed install. The purge removes every legitimate record, clears the manifest, and reports the skipped count.
82
84
 
83
85
  The uninstall runs inside the same snapshot/restore journal as install: prior settings, manifest, managed files, and `core.hooksPath` restore when a later phase fails, so a retry starts from a complete ownership record. The journal is discarded only after a successful commit.
84
86
 
@@ -85,3 +85,54 @@ export const SETTINGS_FILE_NAME = 'settings.json';
85
85
  * removes the file the install created.
86
86
  */
87
87
  export const MYPY_INI_FILE_NAME = '.mypy.ini';
88
+
89
+ /**
90
+ * Environment variable Codex uses for its config home. When unset, Codex reads
91
+ * `~/.codex`. The installer copies shipped exec-policy files into
92
+ * `<that home>/rules`.
93
+ */
94
+ export const CODEX_HOME_ENVIRONMENT_VARIABLE = 'CODEX_HOME';
95
+
96
+ /**
97
+ * Directory name Codex uses under the user home when `CODEX_HOME` is unset.
98
+ */
99
+ export const DEFAULT_CODEX_DIRECTORY_NAME = '.codex';
100
+
101
+ /**
102
+ * Directory name under the Codex home that holds `*.rules` exec-policy files.
103
+ * Codex loads every file in that directory; see `load_exec_policy` in Codex.
104
+ */
105
+ export const CODEX_RULES_DIRECTORY_NAME = 'rules';
106
+
107
+ /**
108
+ * Package subdirectory that holds the shipped Codex exec-policy files. The
109
+ * installer copies this tree into the Codex rules directory, not into
110
+ * `~/.claude/`.
111
+ */
112
+ export const CODEX_RULES_PACKAGE_DIRECTORY_NAME = 'codex-rules';
113
+
114
+ /**
115
+ * Shipped exec-policy file name. A distinct name keeps a local `default.rules`
116
+ * file in place.
117
+ */
118
+ export const CODEX_RULES_SHIPPED_FILE_NAME = 'claude-dev-env.rules';
119
+
120
+ /**
121
+ * Directory name Cursor uses under the user home for editor config.
122
+ */
123
+ export const DEFAULT_CURSOR_DIRECTORY_NAME = '.cursor';
124
+
125
+ /**
126
+ * Directory name under the Cursor home that holds generated `.mdc` rule files.
127
+ */
128
+ export const CURSOR_RULES_DIRECTORY_NAME = 'rules';
129
+
130
+ /**
131
+ * Installed script that writes Cursor `.mdc` files from Claude rules.
132
+ */
133
+ export const CURSOR_SYNC_SCRIPT_FILE_NAME = 'sync_to_cursor.py';
134
+
135
+ /**
136
+ * Windows Python launcher command the installer may bake into hook settings.
137
+ */
138
+ export const WINDOWS_PYTHON_LAUNCHER_COMMAND = 'py -3';
@@ -0,0 +1,173 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { execFileSync } from 'node:child_process';
4
+ import {
5
+ mkdtempSync,
6
+ mkdirSync,
7
+ writeFileSync,
8
+ readFileSync,
9
+ existsSync,
10
+ rmSync,
11
+ } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import {
16
+ resolveInstallRoot,
17
+ isAllowedInstallDestination,
18
+ } from './resolve-install-root.mjs';
19
+ import {
20
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
21
+ CODEX_RULES_DIRECTORY_NAME,
22
+ CODEX_RULES_PACKAGE_DIRECTORY_NAME,
23
+ CODEX_RULES_SHIPPED_FILE_NAME,
24
+ DEFAULT_CODEX_DIRECTORY_NAME,
25
+ } from './install-constants.mjs';
26
+ import { CONTENT_DIRECTORIES, INSTALL_GROUPS } from './install.mjs';
27
+
28
+ const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
29
+ const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
30
+ const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
31
+ const SHIPPED_RULES_SOURCE_PATH = join(
32
+ PACKAGE_DIRECTORY,
33
+ CODEX_RULES_PACKAGE_DIRECTORY_NAME,
34
+ CODEX_RULES_SHIPPED_FILE_NAME,
35
+ );
36
+
37
+ function runInstaller(homeDirectory, extraArguments) {
38
+ return execFileSync('node', [INSTALLER_PATH, ...extraArguments], {
39
+ cwd: PACKAGE_DIRECTORY,
40
+ encoding: 'utf8',
41
+ env: {
42
+ ...process.env,
43
+ HOME: homeDirectory,
44
+ USERPROFILE: homeDirectory,
45
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
46
+ [CODEX_HOME_ENVIRONMENT_VARIABLE]: join(homeDirectory, DEFAULT_CODEX_DIRECTORY_NAME),
47
+ },
48
+ });
49
+ }
50
+
51
+ test('CONTENT_DIRECTORIES omits codex-rules because that tree installs to the Codex home', () => {
52
+ assert.equal(CONTENT_DIRECTORIES.includes(CODEX_RULES_PACKAGE_DIRECTORY_NAME), false);
53
+ });
54
+
55
+ test('the core group installs Codex exec-policy files', () => {
56
+ assert.equal(INSTALL_GROUPS.core.includeCodexRules, true);
57
+ });
58
+
59
+ test('resolveInstallRoot names ~/.codex/rules and allows files under it', () => {
60
+ const homeDirectory = join(tmpdir(), 'cdev-codex-rules-home');
61
+ const resolution = resolveInstallRoot({
62
+ homeDirectory,
63
+ environment: {},
64
+ explicitTarget: null,
65
+ });
66
+ const expectedDirectory = join(homeDirectory, DEFAULT_CODEX_DIRECTORY_NAME, CODEX_RULES_DIRECTORY_NAME);
67
+ assert.equal(resolution.codexRulesInstallDirectory, expectedDirectory);
68
+ assert.equal(
69
+ isAllowedInstallDestination(join(expectedDirectory, CODEX_RULES_SHIPPED_FILE_NAME), resolution),
70
+ true,
71
+ );
72
+ assert.equal(
73
+ isAllowedInstallDestination(join(homeDirectory, '.ssh', 'id_rsa'), resolution),
74
+ false,
75
+ );
76
+ });
77
+
78
+ test('CODEX_HOME relocates the Codex rules destination', () => {
79
+ const homeDirectory = join(tmpdir(), 'cdev-codex-home-default');
80
+ const relocatedHome = join(tmpdir(), 'cdev-codex-home-relocated');
81
+ const resolution = resolveInstallRoot({
82
+ homeDirectory,
83
+ environment: { [CODEX_HOME_ENVIRONMENT_VARIABLE]: relocatedHome },
84
+ explicitTarget: null,
85
+ });
86
+ assert.equal(
87
+ resolution.codexRulesInstallDirectory,
88
+ join(relocatedHome, CODEX_RULES_DIRECTORY_NAME),
89
+ );
90
+ });
91
+
92
+ test('a full install copies shipped Codex rules and leaves a local default.rules in place', () => {
93
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-codex-install-'));
94
+ try {
95
+ const userRulesPath = join(
96
+ homeDirectory,
97
+ DEFAULT_CODEX_DIRECTORY_NAME,
98
+ CODEX_RULES_DIRECTORY_NAME,
99
+ 'default.rules',
100
+ );
101
+ mkdirSync(dirname(userRulesPath), { recursive: true });
102
+ writeFileSync(userRulesPath, 'prefix_rule(pattern=["echo", "hi"], decision="allow")\n');
103
+
104
+ runInstaller(homeDirectory, []);
105
+
106
+ const installedRulesPath = join(
107
+ homeDirectory,
108
+ DEFAULT_CODEX_DIRECTORY_NAME,
109
+ CODEX_RULES_DIRECTORY_NAME,
110
+ CODEX_RULES_SHIPPED_FILE_NAME,
111
+ );
112
+ assert.equal(existsSync(installedRulesPath), true);
113
+ assert.equal(
114
+ readFileSync(installedRulesPath, 'utf8'),
115
+ readFileSync(SHIPPED_RULES_SOURCE_PATH, 'utf8'),
116
+ );
117
+ assert.equal(
118
+ readFileSync(userRulesPath, 'utf8'),
119
+ 'prefix_rule(pattern=["echo", "hi"], decision="allow")\n',
120
+ );
121
+ } finally {
122
+ rmSync(homeDirectory, { recursive: true, force: true });
123
+ }
124
+ });
125
+
126
+ test('a full install Codex rules file contains no personal home path', () => {
127
+ const shippedText = readFileSync(SHIPPED_RULES_SOURCE_PATH, 'utf8');
128
+ assert.equal(shippedText.includes('Users\\jon'), false);
129
+ assert.equal(shippedText.includes('Users/jon'), false);
130
+ assert.equal(shippedText.includes('JonEcho'), false);
131
+ });
132
+
133
+ test('--only journal skips Codex rules; --only core copies them', () => {
134
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-codex-groups-'));
135
+ try {
136
+ const installedRulesPath = join(
137
+ homeDirectory,
138
+ DEFAULT_CODEX_DIRECTORY_NAME,
139
+ CODEX_RULES_DIRECTORY_NAME,
140
+ CODEX_RULES_SHIPPED_FILE_NAME,
141
+ );
142
+ runInstaller(homeDirectory, ['--only', 'journal']);
143
+ assert.equal(existsSync(installedRulesPath), false);
144
+
145
+ runInstaller(homeDirectory, ['--only', 'core']);
146
+ assert.equal(existsSync(installedRulesPath), true);
147
+ } finally {
148
+ rmSync(homeDirectory, { recursive: true, force: true });
149
+ }
150
+ });
151
+
152
+ test('uninstall removes the shipped Codex rules file and leaves default.rules', () => {
153
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-codex-uninstall-'));
154
+ try {
155
+ const rulesDirectory = join(
156
+ homeDirectory,
157
+ DEFAULT_CODEX_DIRECTORY_NAME,
158
+ CODEX_RULES_DIRECTORY_NAME,
159
+ );
160
+ const userRulesPath = join(rulesDirectory, 'default.rules');
161
+ mkdirSync(rulesDirectory, { recursive: true });
162
+ writeFileSync(userRulesPath, 'keep-me\n');
163
+
164
+ runInstaller(homeDirectory, []);
165
+ runInstaller(homeDirectory, ['--uninstall']);
166
+
167
+ const installedRulesPath = join(rulesDirectory, CODEX_RULES_SHIPPED_FILE_NAME);
168
+ assert.equal(existsSync(installedRulesPath), false);
169
+ assert.equal(readFileSync(userRulesPath, 'utf8'), 'keep-me\n');
170
+ } finally {
171
+ rmSync(homeDirectory, { recursive: true, force: true });
172
+ }
173
+ });
@@ -0,0 +1,103 @@
1
+ import { test } from 'node:test';
2
+ import { strict as assert } from 'node:assert';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { execFileSync } from 'node:child_process';
8
+ import {
9
+ resolveInstallRoot,
10
+ isAllowedInstallDestination,
11
+ } from './resolve-install-root.mjs';
12
+ import {
13
+ DEFAULT_CURSOR_DIRECTORY_NAME,
14
+ CURSOR_RULES_DIRECTORY_NAME,
15
+ } from './install-constants.mjs';
16
+
17
+ const THIS_DIRECTORY = dirname(fileURLToPath(import.meta.url));
18
+ const INSTALLER_PATH = join(THIS_DIRECTORY, 'install.mjs');
19
+ const PACKAGE_DIRECTORY = dirname(THIS_DIRECTORY);
20
+
21
+ function runInstaller(homeDirectory, extraArguments) {
22
+ return execFileSync('node', [INSTALLER_PATH, ...extraArguments], {
23
+ cwd: PACKAGE_DIRECTORY,
24
+ encoding: 'utf8',
25
+ env: {
26
+ ...process.env,
27
+ HOME: homeDirectory,
28
+ USERPROFILE: homeDirectory,
29
+ GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
30
+ },
31
+ });
32
+ }
33
+
34
+ test('resolveInstallRoot names ~/.cursor/rules and allows generated mdc files under it', () => {
35
+ const homeDirectory = join(tmpdir(), 'cdev-cursor-rules-home');
36
+ const resolution = resolveInstallRoot({
37
+ homeDirectory,
38
+ environment: {},
39
+ explicitTarget: null,
40
+ });
41
+ const expectedDirectory = join(
42
+ homeDirectory,
43
+ DEFAULT_CURSOR_DIRECTORY_NAME,
44
+ CURSOR_RULES_DIRECTORY_NAME,
45
+ );
46
+ assert.equal(resolution.cursorRulesInstallDirectory, expectedDirectory);
47
+ assert.equal(
48
+ isAllowedInstallDestination(join(expectedDirectory, 'plain-language.mdc'), resolution),
49
+ true,
50
+ );
51
+ assert.equal(
52
+ isAllowedInstallDestination(join(homeDirectory, '.ssh', 'id_rsa'), resolution),
53
+ false,
54
+ );
55
+ });
56
+
57
+ test('a full install writes stem-named Cursor rules and leaves a local extra mdc in place', () => {
58
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-cursor-install-'));
59
+ try {
60
+ const extraRulePath = join(
61
+ homeDirectory,
62
+ DEFAULT_CURSOR_DIRECTORY_NAME,
63
+ CURSOR_RULES_DIRECTORY_NAME,
64
+ 'user-local.mdc',
65
+ );
66
+ mkdirSync(dirname(extraRulePath), { recursive: true });
67
+ writeFileSync(extraRulePath, 'keep-me\n');
68
+
69
+ runInstaller(homeDirectory, []);
70
+
71
+ const generatedPath = join(
72
+ homeDirectory,
73
+ DEFAULT_CURSOR_DIRECTORY_NAME,
74
+ CURSOR_RULES_DIRECTORY_NAME,
75
+ 'plain-language.mdc',
76
+ );
77
+ assert.equal(existsSync(generatedPath), true);
78
+ const generatedText = readFileSync(generatedPath, 'utf8');
79
+ assert.equal(generatedText.includes('alwaysApply: true'), true);
80
+ assert.equal(readFileSync(extraRulePath, 'utf8'), 'keep-me\n');
81
+ } finally {
82
+ rmSync(homeDirectory, { recursive: true, force: true });
83
+ }
84
+ });
85
+
86
+ test('--only journal skips Cursor rule generation; --only core writes them', () => {
87
+ const homeDirectory = mkdtempSync(join(tmpdir(), 'cdev-cursor-groups-'));
88
+ try {
89
+ const generatedPath = join(
90
+ homeDirectory,
91
+ DEFAULT_CURSOR_DIRECTORY_NAME,
92
+ CURSOR_RULES_DIRECTORY_NAME,
93
+ 'plain-language.mdc',
94
+ );
95
+ runInstaller(homeDirectory, ['--only', 'journal']);
96
+ assert.equal(existsSync(generatedPath), false);
97
+
98
+ runInstaller(homeDirectory, ['--only', 'core']);
99
+ assert.equal(existsSync(generatedPath), true);
100
+ } finally {
101
+ rmSync(homeDirectory, { recursive: true, force: true });
102
+ }
103
+ });
package/bin/install.mjs CHANGED
@@ -22,10 +22,14 @@ import {
22
22
  MANAGED_SKILLS_DIRECTORY_NAME,
23
23
  MANAGED_HOOKS_DIRECTORY_NAME,
24
24
  SETTINGS_FILE_NAME,
25
+ CODEX_RULES_PACKAGE_DIRECTORY_NAME,
26
+ CURSOR_SYNC_SCRIPT_FILE_NAME,
27
+ WINDOWS_PYTHON_LAUNCHER_COMMAND,
25
28
  } from './install-constants.mjs';
26
29
  import {
27
30
  resolveInstallRoot,
28
31
  parseExplicitTargetFromArgv,
32
+ isAllowedInstallDestination,
29
33
  } from './resolve-install-root.mjs';
30
34
  import {
31
35
  parseInstallTargetSelectionFromArgv,
@@ -211,7 +215,7 @@ function discoverDependencyGroups() {
211
215
  readFileSync(join(dependencyRoot, 'package.json'), 'utf8')
212
216
  );
213
217
  const groupName = dependencyPackageJson.claudeDevEnv?.groupName
214
- || dependencyName.replace(/^@[^/]+\//, '');
218
+ || dependencyName.replace(new RegExp('^@[^/]+/'), '');
215
219
  const group = {
216
220
  description: dependencyPackageJson.description || dependencyName,
217
221
  packageRoot: dependencyRoot,
@@ -257,6 +261,7 @@ export const INSTALL_GROUPS = {
257
261
  skills: CORE_SKILLS,
258
262
  includeDirectories: CORE_INCLUDE_DIRECTORIES,
259
263
  includeAllHooks: true,
264
+ includeCodexRules: true,
260
265
  },
261
266
  journal: {
262
267
  description: 'Session logging and memory',
@@ -302,6 +307,75 @@ export function isWindowsStorePythonStub(executablePath) {
302
307
  return /[\\/]windowsapps[\\/]/i.test(executablePath);
303
308
  }
304
309
 
310
+ /**
311
+ * Split a stored Python command into an executable and prefix arguments.
312
+ *
313
+ * Args:
314
+ * pythonCommand: The command the installer detected (`py -3`, `python3`, or a path).
315
+ *
316
+ * Returns:
317
+ * `{ file, prefixArguments }` for `execFileSync`.
318
+ */
319
+ export function pythonFileAndPrefixArguments(pythonCommand) {
320
+ if (pythonCommand === WINDOWS_PYTHON_LAUNCHER_COMMAND) {
321
+ return { file: 'py', prefixArguments: ['-3'] };
322
+ }
323
+ const unquoted = pythonCommand.replace(/^"(.*)"$/, '$1');
324
+ return { file: unquoted, prefixArguments: [] };
325
+ }
326
+
327
+ /**
328
+ * Read generated Cursor paths from the sync manifest under a Cursor home.
329
+ *
330
+ * Args:
331
+ * cursorRoot: Absolute `.cursor` directory.
332
+ *
333
+ * Returns:
334
+ * Absolute paths the installer may record, including the sync manifest.
335
+ */
336
+ export function collectManagedCursorSyncPaths(cursorRoot) {
337
+ const manifestPath = join(cursorRoot, '.sync-manifest.json');
338
+ if (!existsSync(manifestPath)) return [];
339
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
340
+ const generatedPaths = [manifestPath];
341
+ const allEntries = { ...(parsed.entries || {}), ...(parsed.docs_entries || {}) };
342
+ for (const eachRelativePath of Object.keys(allEntries)) {
343
+ generatedPaths.push(join(cursorRoot, eachRelativePath));
344
+ }
345
+ return generatedPaths;
346
+ }
347
+
348
+ /**
349
+ * Run the installed Cursor rule generator against Claude and Cursor roots.
350
+ *
351
+ * Args:
352
+ * pythonCommand: Interpreter command from install preflight.
353
+ * scriptPath: Absolute `sync_to_cursor.py` path.
354
+ * claudeRoot: Managed Claude root that holds `rules/` and `docs/`.
355
+ * cursorRoot: Cursor home that receives `rules/*.mdc`.
356
+ *
357
+ * Returns:
358
+ * void
359
+ */
360
+ export function runCursorRuleSync(pythonCommand, scriptPath, claudeRoot, cursorRoot) {
361
+ mkdirSync(cursorRoot, { recursive: true });
362
+ const { file, prefixArguments } = pythonFileAndPrefixArguments(pythonCommand);
363
+ execFileSync(
364
+ file,
365
+ [
366
+ ...prefixArguments,
367
+ scriptPath,
368
+ '--force',
369
+ '--quiet',
370
+ '--claude-root',
371
+ claudeRoot,
372
+ '--cursor-root',
373
+ cursorRoot,
374
+ ],
375
+ { stdio: 'inherit' },
376
+ );
377
+ }
378
+
305
379
  /**
306
380
  * Formats an absolute interpreter path as a settings.json hook command prefix:
307
381
  * forward-slash separators, double-quoted when the path contains a space so the
@@ -629,8 +703,7 @@ function isManagedPath(candidatePath, managedHomeDirectory = CLAUDE_HOME) {
629
703
  * @returns {boolean} True when the installer itself writes the path.
630
704
  */
631
705
  function isRemovableManifestRecord(candidatePath) {
632
- if (isManagedPath(candidatePath)) return true;
633
- return comparisonKeyForPath(candidatePath) === comparisonKeyForPath(MYPY_INI_INSTALL_PATH);
706
+ return isAllowedInstallDestination(candidatePath, INSTALL_ROOT_RESOLUTION);
634
707
  }
635
708
 
636
709
  /**
@@ -650,6 +723,10 @@ function owningManagedRoot(installedFilePath) {
650
723
  const managedRoot = join(CLAUDE_HOME, directoryName);
651
724
  if (isInsideDirectory(resolvedPath, managedRoot)) return managedRoot;
652
725
  }
726
+ const codexRulesDirectory = INSTALL_ROOT_RESOLUTION.codexRulesInstallDirectory;
727
+ if (isInsideDirectory(resolvedPath, codexRulesDirectory)) return codexRulesDirectory;
728
+ const cursorInstallDirectory = INSTALL_ROOT_RESOLUTION.cursorInstallDirectory;
729
+ if (isInsideDirectory(resolvedPath, cursorInstallDirectory)) return cursorInstallDirectory;
653
730
  return null;
654
731
  }
655
732
 
@@ -1856,7 +1933,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
1856
1933
  `${PACKAGE_NAME}: --update — removing prior managed files under ${CLAUDE_HOME}, then reinstalling from the package.\n`,
1857
1934
  );
1858
1935
  purgeManagedInstallation({
1859
- requireManifest: false,
1936
+ isManifestRequired: false,
1860
1937
  throwIfFault,
1861
1938
  });
1862
1939
  } else if (isUpdateRefresh) {
@@ -1940,6 +2017,30 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
1940
2017
  }
1941
2018
  }
1942
2019
  }
2020
+ const shouldInstallCodexRules = !selectedGroups
2021
+ || activeGroups.some((eachGroup) => eachGroup.includeCodexRules);
2022
+ if (shouldInstallCodexRules) {
2023
+ const sourceDirectory = join(PACKAGE_ROOT, CODEX_RULES_PACKAGE_DIRECTORY_NAME);
2024
+ if (existsSync(sourceDirectory)) {
2025
+ const destinationDirectory = INSTALL_ROOT_RESOLUTION.codexRulesInstallDirectory;
2026
+ const stats = copyTree(sourceDirectory, destinationDirectory);
2027
+ summary.codexRules = stats;
2028
+ allInstalledFiles.push(...stats.paths);
2029
+ }
2030
+ }
2031
+ const shouldInstallCursorRules = !selectedGroups
2032
+ || activeGroups.some((eachGroup) => (eachGroup.includeDirectories || []).includes('rules'));
2033
+ if (shouldInstallCursorRules) {
2034
+ const scriptPath = join(CLAUDE_HOME, 'scripts', CURSOR_SYNC_SCRIPT_FILE_NAME);
2035
+ if (!existsSync(scriptPath)) {
2036
+ throw new Error(`cursor rule sync script missing: ${scriptPath}`);
2037
+ }
2038
+ const cursorRoot = dirname(INSTALL_ROOT_RESOLUTION.cursorRulesInstallDirectory);
2039
+ runCursorRuleSync(pythonCommand, scriptPath, CLAUDE_HOME, cursorRoot);
2040
+ const generatedCursorPaths = collectManagedCursorSyncPaths(cursorRoot);
2041
+ allInstalledFiles.push(...generatedCursorPaths);
2042
+ summary.cursorRules = { created: generatedCursorPaths.length, updated: 0, paths: generatedCursorPaths };
2043
+ }
1943
2044
  let skillsCreated = 0;
1944
2045
  let skillsUpdated = 0;
1945
2046
  const skillPaths = [];
@@ -2136,6 +2237,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2136
2237
  console.log(` ${directory}: ${created + updated} files (${created} new, ${updated} updated)`);
2137
2238
  }
2138
2239
  }
2240
+ if (summary.codexRules) {
2241
+ const { created, updated } = summary.codexRules;
2242
+ console.log(` ${CODEX_RULES_PACKAGE_DIRECTORY_NAME}: ${created + updated} files (${created} new, ${updated} updated)`);
2243
+ }
2244
+ if (summary.cursorRules) {
2245
+ const { created } = summary.cursorRules;
2246
+ console.log(` cursor-rules: ${created} generated files`);
2247
+ }
2139
2248
  if (summary.skills) {
2140
2249
  const { created, updated, pruned } = summary.skills;
2141
2250
  const staleClause = pruned > 0 ? `, ${pruned} stale moved aside` : '';
@@ -2222,14 +2331,14 @@ function removeRecordedFile(filePath) {
2222
2331
  /**
2223
2332
  * Build the uninstall plan for this managed root.
2224
2333
  *
2225
- * @param {boolean} requireManifest
2334
+ * @param {boolean} isManifestRequired
2226
2335
  * @returns {ReturnType<typeof buildUninstallPlan>}
2227
2336
  */
2228
- function resolveUninstallPlan(requireManifest) {
2337
+ function resolveUninstallPlan(isManifestRequired) {
2229
2338
  return buildUninstallPlan({
2230
2339
  managedRoot: CLAUDE_HOME,
2231
2340
  manifestFilePath: MANIFEST_FILE,
2232
- requireManifest,
2341
+ requireManifest: isManifestRequired,
2233
2342
  isRemovableRecord: isRemovableManifestRecord,
2234
2343
  });
2235
2344
  }
@@ -2266,20 +2375,20 @@ function executeUninstallPlan(plan, helpers = {}) {
2266
2375
  }
2267
2376
  if (plan.skippedFiles.length > 0) {
2268
2377
  console.warn(
2269
- ` ${plan.skippedFiles.length} manifest record(s) skipped — each names a path outside ${CLAUDE_HOME} and outside ${MYPY_INI_INSTALL_PATH}`,
2378
+ ` ${plan.skippedFiles.length} manifest record(s) skipped — each names a path outside ${CLAUDE_HOME}, outside ${MYPY_INI_INSTALL_PATH}, outside ${INSTALL_ROOT_RESOLUTION.codexRulesInstallDirectory}, and outside ${INSTALL_ROOT_RESOLUTION.cursorInstallDirectory}`,
2270
2379
  );
2271
2380
  }
2272
2381
  throwIfFault(FAULT_PHASES.AFTER_FILE_STAGING);
2273
2382
 
2274
2383
  if (existsSync(plan.settingsPath)) {
2275
2384
  const settings = JSON.parse(readFileSync(plan.settingsPath, 'utf8'));
2276
- let settingsChanged = false;
2385
+ let didSettingsChange = false;
2277
2386
  if (settings.hooks) {
2278
2387
  const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
2279
2388
  managedPackageSourceRoots(),
2280
2389
  );
2281
2390
  pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
2282
- settingsChanged = true;
2391
+ didSettingsChange = true;
2283
2392
  console.log(' Hook entries removed from settings.json');
2284
2393
  }
2285
2394
  const managedDenyFromPlan = plan.managedPermissionDenyEntries.length > 0
@@ -2288,13 +2397,13 @@ function executeUninstallPlan(plan, helpers = {}) {
2288
2397
  if (managedDenyFromPlan.length > 0) {
2289
2398
  const pruneOutcome = pruneManagedPermissionsFromSettings(settings, managedDenyFromPlan);
2290
2399
  if (pruneOutcome.removedCount > 0) {
2291
- settingsChanged = true;
2400
+ didSettingsChange = true;
2292
2401
  console.log(
2293
2402
  ` Permission entries removed from settings.json: ${pruneOutcome.removedCount} managed deny(s)`,
2294
2403
  );
2295
2404
  }
2296
2405
  }
2297
- if (settingsChanged) {
2406
+ if (didSettingsChange) {
2298
2407
  writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
2299
2408
  }
2300
2409
  }
@@ -2327,13 +2436,13 @@ function executeUninstallPlan(plan, helpers = {}) {
2327
2436
  * without nesting a second journal.
2328
2437
  *
2329
2438
  * @param {{
2330
- * requireManifest: boolean,
2439
+ * isManifestRequired: boolean,
2331
2440
  * throwIfFault?: (phase: string) => void,
2332
2441
  * }} options
2333
2442
  * @returns {number|void} 0 when no manifest exists and none is required.
2334
2443
  */
2335
- function purgeManagedInstallation({ requireManifest, throwIfFault }) {
2336
- const plan = resolveUninstallPlan(requireManifest);
2444
+ function purgeManagedInstallation({ isManifestRequired, throwIfFault }) {
2445
+ const plan = resolveUninstallPlan(isManifestRequired);
2337
2446
  if (plan.isNoOp) {
2338
2447
  return 0;
2339
2448
  }
@@ -2433,6 +2542,8 @@ Examples:
2433
2542
 
2434
2543
  Install location: ~/.claude/ by default; CLAUDE_CONFIG_DIR or --target selects another managed root.
2435
2544
  Named profiles resolve under LLM_SETTINGS_PROFILES_ROOT or ~/.claude-profiles/<directoryName>.
2545
+ Codex exec-policy files copy into ~/.codex/rules, or CODEX_HOME/rules when CODEX_HOME is set.
2546
+ Cursor rule files generate into ~/.cursor/rules as stem-named mdc files, one per Claude rule.
2436
2547
 
2437
2548
  Root precedence: --target > CLAUDE_CONFIG_DIR > ~/.claude
2438
2549
  Profile selection (--profile/--profiles) is mutually exclusive with --target.
@@ -2564,7 +2675,7 @@ function runInstallForAllTargets(allTargets, childArgv) {
2564
2675
  * @returns {number | null}
2565
2676
  */
2566
2677
  function spawnInstallChild(target, childArgv) {
2567
- const result = spawnSync(
2678
+ const childProcess = spawnSync(
2568
2679
  process.execPath,
2569
2680
  [
2570
2681
  fileURLToPath(import.meta.url),
@@ -2579,11 +2690,11 @@ function spawnInstallChild(target, childArgv) {
2579
2690
  env: process.env,
2580
2691
  },
2581
2692
  );
2582
- if (result.error) {
2583
- console.error(`ERROR: failed to spawn install child: ${result.error.message}`);
2693
+ if (childProcess.error) {
2694
+ console.error(`ERROR: failed to spawn install child: ${childProcess.error.message}`);
2584
2695
  return 1;
2585
2696
  }
2586
- return result.status === null ? 1 : result.status;
2697
+ return childProcess.status === null ? 1 : childProcess.status;
2587
2698
  }
2588
2699
 
2589
2700
  if (invokedAsEntryPoint(import.meta.url, process.argv[1])) {
@@ -8,6 +8,7 @@ import { join, resolve } from 'node:path';
8
8
  import { mkdtempSync, rmSync, mkdirSync, readFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import { CODEX_RULES_SHIPPED_FILE_NAME } from './install-constants.mjs';
11
12
  import {
12
13
  resolveInstallRoot,
13
14
  isPathWithinManagedRoot,
@@ -90,6 +91,13 @@ test('declared external mypy.ini is allowed; unrelated external paths are not',
90
91
  isAllowedInstallDestination(join(resolution.managedRoot, 'hooks', 'x.py'), resolution),
91
92
  true,
92
93
  );
94
+ assert.equal(
95
+ isAllowedInstallDestination(
96
+ join(resolution.codexRulesInstallDirectory, CODEX_RULES_SHIPPED_FILE_NAME),
97
+ resolution,
98
+ ),
99
+ true,
100
+ );
93
101
  });
94
102
 
95
103
  test('parseExplicitTargetFromArgv reads --target and --target=', () => {
@@ -238,6 +238,7 @@ function resolveInstallerInvocation(homeDirectory, options) {
238
238
  HOME: homeDirectory,
239
239
  USERPROFILE: homeDirectory,
240
240
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
241
+ CODEX_HOME: join(homeDirectory, '.codex'),
241
242
  };
242
243
  if (dependencyResolvable) {
243
244
  childEnvironment.NODE_PATH = ensureDependencyStub(homeDirectory);
@@ -1227,7 +1228,7 @@ test('an uninstall removes the home-directory .mypy.ini the install wrote and sk
1227
1228
  assert.equal(
1228
1229
  existsSync(mypyIniPath),
1229
1230
  false,
1230
- 'the uninstall removes the one file the install writes outside ~/.claude',
1231
+ 'the uninstall removes the mypy configuration the install writes outside ~/.claude',
1231
1232
  );
1232
1233
  assert.equal(
1233
1234
  installerOutput.includes(`skipping ${mypyIniPath}`),
@@ -242,6 +242,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
242
242
  HOME: homeDirectory,
243
243
  USERPROFILE: homeDirectory,
244
244
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
245
+ CODEX_HOME: join(homeDirectory, '.codex'),
245
246
  };
246
247
  if (options.faultPhase) {
247
248
  childEnvironment[INSTALL_FAULT_ENV] = options.faultPhase;
@@ -74,6 +74,7 @@ function runInstaller(homeDirectory, extraArguments, options = {}) {
74
74
  HOME: homeDirectory,
75
75
  USERPROFILE: homeDirectory,
76
76
  GIT_CONFIG_GLOBAL: join(homeDirectory, '.gitconfig'),
77
+ CODEX_HOME: join(homeDirectory, '.codex'),
77
78
  };
78
79
  if (options.faultPhase) {
79
80
  childEnvironment[INSTALL_FAULT_ENV] = options.faultPhase;
@@ -11,7 +11,14 @@
11
11
 
12
12
  import { homedir } from 'node:os';
13
13
  import { join, normalize, resolve, sep } from 'node:path';
14
- import { MYPY_INI_FILE_NAME } from './install-constants.mjs';
14
+ import {
15
+ MYPY_INI_FILE_NAME,
16
+ CODEX_HOME_ENVIRONMENT_VARIABLE,
17
+ DEFAULT_CODEX_DIRECTORY_NAME,
18
+ CODEX_RULES_DIRECTORY_NAME,
19
+ DEFAULT_CURSOR_DIRECTORY_NAME,
20
+ CURSOR_RULES_DIRECTORY_NAME,
21
+ } from './install-constants.mjs';
15
22
 
16
23
  export const CLAUDE_CONFIG_DIR_ENVIRONMENT_VARIABLE = 'CLAUDE_CONFIG_DIR';
17
24
  export const DEFAULT_CLAUDE_DIRECTORY_NAME = '.claude';
@@ -33,6 +40,10 @@ export const MANIFEST_FILE_NAME = '.claude-dev-env-manifest.json';
33
40
  * manifestFilePath: string,
34
41
  * mypyIniInstallPath: string,
35
42
  * allDeclaredExternalPaths: string[],
43
+ * allDeclaredExternalDirectories: string[],
44
+ * codexRulesInstallDirectory: string,
45
+ * cursorInstallDirectory: string,
46
+ * cursorRulesInstallDirectory: string,
36
47
  * }} InstallRootResolution
37
48
  */
38
49
 
@@ -69,6 +80,18 @@ export function resolveInstallRoot(options = {}) {
69
80
  }
70
81
 
71
82
  const mypyIniInstallPath = resolve(join(homeDirectory, MYPY_INI_FILE_NAME));
83
+ const codexHomeDirectory = normalizeOptionalPath(
84
+ environment[CODEX_HOME_ENVIRONMENT_VARIABLE],
85
+ ) ?? resolve(join(homeDirectory, DEFAULT_CODEX_DIRECTORY_NAME));
86
+ const codexRulesInstallDirectory = resolve(
87
+ join(codexHomeDirectory, CODEX_RULES_DIRECTORY_NAME),
88
+ );
89
+ const cursorInstallDirectory = resolve(
90
+ join(homeDirectory, DEFAULT_CURSOR_DIRECTORY_NAME),
91
+ );
92
+ const cursorRulesInstallDirectory = resolve(
93
+ join(cursorInstallDirectory, CURSOR_RULES_DIRECTORY_NAME),
94
+ );
72
95
  return {
73
96
  managedRoot,
74
97
  source,
@@ -76,6 +99,10 @@ export function resolveInstallRoot(options = {}) {
76
99
  manifestFilePath: join(managedRoot, MANIFEST_FILE_NAME),
77
100
  mypyIniInstallPath,
78
101
  allDeclaredExternalPaths: [mypyIniInstallPath],
102
+ allDeclaredExternalDirectories: [codexRulesInstallDirectory, cursorInstallDirectory],
103
+ codexRulesInstallDirectory,
104
+ cursorInstallDirectory,
105
+ cursorRulesInstallDirectory,
79
106
  };
80
107
  }
81
108
 
@@ -103,8 +130,9 @@ export function isPathWithinManagedRoot(candidatePath, managedRoot) {
103
130
  }
104
131
 
105
132
  /**
106
- * True when a write destination is allowed: inside the managed root or on
107
- * the declared external allowlist (today: ~/.mypy.ini under the home dir).
133
+ * True when a write destination is allowed: inside the managed root, the
134
+ * home-directory `.mypy.ini`, a file under the Codex rules directory, or a file
135
+ * under the Cursor rules directory.
108
136
  *
109
137
  * @param {string} candidatePath
110
138
  * @param {InstallRootResolution} resolution
@@ -115,8 +143,13 @@ export function isAllowedInstallDestination(candidatePath, resolution) {
115
143
  return true;
116
144
  }
117
145
  const normalizedCandidate = normalizePathForComparison(candidatePath);
118
- return resolution.allDeclaredExternalPaths.some(
146
+ if (resolution.allDeclaredExternalPaths.some(
119
147
  (eachExternalPath) => normalizePathForComparison(eachExternalPath) === normalizedCandidate,
148
+ )) {
149
+ return true;
150
+ }
151
+ return (resolution.allDeclaredExternalDirectories ?? []).some(
152
+ (eachExternalDirectory) => isPathWithinManagedRoot(candidatePath, eachExternalDirectory),
120
153
  );
121
154
  }
122
155
 
@@ -130,18 +163,18 @@ export function parseExplicitTargetFromArgv(argv) {
130
163
  for (let index = 0; index < argv.length; index += 1) {
131
164
  const token = argv[index];
132
165
  if (token === '--target') {
133
- const value = argv[index + 1];
134
- if (!value || value.startsWith('--')) {
166
+ const targetPath = argv[index + 1];
167
+ if (!targetPath || targetPath.startsWith('--')) {
135
168
  throw new Error('--target requires a path argument');
136
169
  }
137
- return value;
170
+ return targetPath;
138
171
  }
139
172
  if (token.startsWith('--target=')) {
140
- const value = token.slice('--target='.length);
141
- if (!value) {
173
+ const targetPath = token.slice('--target='.length);
174
+ if (!targetPath) {
142
175
  throw new Error('--target requires a path argument');
143
176
  }
144
- return value;
177
+ return targetPath;
145
178
  }
146
179
  }
147
180
  return null;
@@ -0,0 +1,12 @@
1
+ # Shared Codex exec-policy prefix rules shipped by claude-dev-env.
2
+ # Codex loads every *.rules file under $CODEX_HOME/rules (default ~/.codex/rules).
3
+ # This file is named claude-dev-env.rules so a local default.rules stays in place.
4
+ # Patterns use relative repo paths only. Personal home paths stay out of this file.
5
+
6
+ prefix_rule(pattern=["gemini", "--version"], decision="allow")
7
+ prefix_rule(pattern=["git", "status", "--short", "--branch"], decision="allow")
8
+ prefix_rule(pattern=["git", "status", "--short"], decision="allow")
9
+ prefix_rule(pattern=["git", "diff", "--", "packages/claude-dev-env/_shared/pr-loop/scripts/code_rules_gate_parts", "packages/claude-dev-env/_shared/pr-loop/scripts/tests/test_code_rules_gate.py"], decision="allow")
10
+ prefix_rule(pattern=["Get-Content", "-LiteralPath", "packages/claude-dev-env/_shared/pr-loop/scripts/code_rules_gate_parts/tests/_repo_test_helpers.py"], decision="allow")
11
+ prefix_rule(pattern=["rg", "-n", "-i", "--glob", "!**/__pycache__/**", "verified_commit_gate|verifier_verdict_minter|verdict_directory_write_blocker|verify-skip|code-verifier.*commit|commit.*code-verifier", "packages/claude-dev-env"], decision="allow")
12
+ prefix_rule(pattern=["python", "-m", "pytest", "-q", "packages/claude-dev-env/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py", "packages/claude-dev-env/hooks/blocking/test_bash_pre_tool_use_dispatcher.py", "packages/claude-dev-env/hooks/blocking/test_pre_tool_use_dispatcher.py", "packages/claude-dev-env/hooks/blocking/test_verdict_directory_write_blocker.py"], decision="allow")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-dev-env",
3
- "version": "2.14.1",
3
+ "version": "2.15.0",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "installable-surfaces.manifest.json",
27
27
  "_shared/",
28
28
  "audit-rubrics/",
29
+ "codex-rules/",
29
30
  "AGENTS.md",
30
31
  "CLAUDE.md",
31
32
  "!**/__pycache__/**",
@@ -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
+ )
@@ -10,6 +10,8 @@ pytest suite for the Python scripts and Pester suite for the PowerShell scripts
10
10
  | `test_setup_project_paths_config.py` | Configuration constants used by `setup_project_paths.py` |
11
11
  | `test_sweep_empty_dirs.py` | `sweep_empty_dirs.py` — age check, one-shot mode, and continuous-watch behavior |
12
12
  | `test_sync_to_cursor.py` | `sync_to_cursor/` package — mapping, hashing, manifest, and path resolution |
13
+ | `test_rules.py` | Discovered Claude `rules/*.md` mappings to stem-named Cursor `.mdc` files |
14
+ | `test_engine.py` | `sync_to_cursor` engine `--claude-root` / `--cursor-root` layout flags |
13
15
  | `test_grok_worker_constants.py` | `grok_worker_constants.py` — the accepted batch worker-key set stays in step with the worker key constants, and the unknown-key message names both its placeholders |
14
16
 
15
17
  ## PowerShell test files
@@ -0,0 +1,102 @@
1
+ """Tests for sync_to_cursor engine CLI roots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ _SCRIPTS_DIR = Path(__file__).resolve().parent.parent
11
+ if str(_SCRIPTS_DIR) not in sys.path:
12
+ sys.path.insert(0, str(_SCRIPTS_DIR))
13
+
14
+ from sync_to_cursor.engine import run as run_sync_to_cursor
15
+
16
+ _CODE_STANDARDS_SECTION_ORDER = (
17
+ "COMMENT PRESERVATION",
18
+ "CORE PRINCIPLES",
19
+ "⚡ HOOK-ENFORCED RULES",
20
+ "3. REUSE CONSTANTS / 4. CONFIG LOCATIONS",
21
+ "5. NO ABBREVIATIONS",
22
+ "6. COMPLETE TYPE HINTS",
23
+ "9. SELF-CONTAINED COMPONENTS",
24
+ )
25
+ _TEST_QUALITY_SECTION_ORDER = (
26
+ "Delete Useless Tests",
27
+ "Test Dependencies MUST FAIL",
28
+ "Core Testing Principles",
29
+ "React Testing Patterns",
30
+ "Test File Organization",
31
+ )
32
+
33
+
34
+ def _write_minimal_curated_rules(rules_directory: Path) -> None:
35
+ rules_directory.mkdir(parents=True, exist_ok=True)
36
+ (rules_directory / "code-standards.md").write_text(
37
+ "# Code standards stub\n", encoding="utf-8"
38
+ )
39
+ (rules_directory / "tasklings-preferences.md").write_text(
40
+ '---\npaths:\n - "Y:/x/**"\n---\n\n# Tasklings\n',
41
+ encoding="utf-8",
42
+ )
43
+ (rules_directory / "bdd.md").write_text("# BDD\n", encoding="utf-8")
44
+ (rules_directory / "testing.md").write_text(
45
+ '---\npaths:\n - "**/test_*.py"\n---\n\n# Testing\n',
46
+ encoding="utf-8",
47
+ )
48
+ (rules_directory / "research-mode.md").write_text("# RM\n", encoding="utf-8")
49
+ (rules_directory / "conservative-action.md").write_text("# CA\n", encoding="utf-8")
50
+ (rules_directory / "explore-thoroughly.md").write_text("# ET\n", encoding="utf-8")
51
+
52
+
53
+ def _write_minimal_docs(docs_directory: Path) -> None:
54
+ docs_directory.mkdir(parents=True, exist_ok=True)
55
+ (docs_directory / "CODE_RULES.md").write_text(
56
+ "\n\n".join(f"## {title}\n\nalpha" for title in _CODE_STANDARDS_SECTION_ORDER)
57
+ + "\n",
58
+ encoding="utf-8",
59
+ )
60
+ (docs_directory / "TEST_QUALITY.md").write_text(
61
+ "\n\n".join(f"## {title}\n\nbeta" for title in _TEST_QUALITY_SECTION_ORDER)
62
+ + "\n",
63
+ encoding="utf-8",
64
+ )
65
+
66
+
67
+ def test_explicit_roots_write_stem_named_mdc(
68
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
69
+ ) -> None:
70
+ claude = tmp_path / "claude-home"
71
+ cursor = tmp_path / "cursor-home"
72
+ _write_minimal_curated_rules(claude / "rules")
73
+ _write_minimal_docs(claude / "docs")
74
+ (claude / "rules" / "plain-language.md").write_text(
75
+ "# Plain language\n\nUse short sentences.\n",
76
+ encoding="utf-8",
77
+ )
78
+ (claude / "rules" / "CLAUDE.md").write_text("# Inventory\n", encoding="utf-8")
79
+ monkeypatch.delenv("LLM_SETTINGS_ROOT", raising=False)
80
+ assert cursor.exists() is False
81
+ assert (
82
+ run_sync_to_cursor(
83
+ [
84
+ "--force",
85
+ "--claude-root",
86
+ str(claude),
87
+ "--cursor-root",
88
+ str(cursor),
89
+ ]
90
+ )
91
+ == 0
92
+ )
93
+ generated = (cursor / "rules" / "plain-language.mdc").read_text(encoding="utf-8")
94
+ assert 'description: "Plain language"' in generated
95
+ assert "alwaysApply: true" in generated
96
+ assert "Use short sentences." in generated
97
+ assert not (cursor / "rules" / "CLAUDE.mdc").is_file()
98
+
99
+
100
+ def test_explicit_roots_require_both_flags(tmp_path: Path) -> None:
101
+ with pytest.raises(SystemExit):
102
+ run_sync_to_cursor(["--force", "--claude-root", str(tmp_path)])
@@ -0,0 +1,79 @@
1
+ """Tests for discovered Claude-to-Cursor rule mappings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ _SCRIPTS_DIR = Path(__file__).resolve().parent.parent
9
+ if str(_SCRIPTS_DIR) not in sys.path:
10
+ sys.path.insert(0, str(_SCRIPTS_DIR))
11
+
12
+ from sync_to_cursor.rules import build_mappings
13
+
14
+ _PACKAGE_ROOT = _SCRIPTS_DIR.parent
15
+ _SKIPPED_RULE_FILE_NAMES = frozenset({"CLAUDE.md", "AGENTS.md"})
16
+
17
+
18
+ def _write_minimal_curated_rules(rules_directory: Path) -> None:
19
+ rules_directory.mkdir(parents=True, exist_ok=True)
20
+ (rules_directory / "code-standards.md").write_text(
21
+ "# Code standards stub\n", encoding="utf-8"
22
+ )
23
+ (rules_directory / "tasklings-preferences.md").write_text(
24
+ '---\npaths:\n - "Y:/x/**"\n---\n\n# Tasklings\n',
25
+ encoding="utf-8",
26
+ )
27
+ (rules_directory / "bdd.md").write_text("# BDD\n", encoding="utf-8")
28
+ (rules_directory / "testing.md").write_text(
29
+ '---\npaths:\n - "**/test_*.py"\n---\n\n# Testing\n',
30
+ encoding="utf-8",
31
+ )
32
+ (rules_directory / "research-mode.md").write_text("# RM\n", encoding="utf-8")
33
+ (rules_directory / "conservative-action.md").write_text("# CA\n", encoding="utf-8")
34
+ (rules_directory / "explore-thoroughly.md").write_text("# ET\n", encoding="utf-8")
35
+
36
+
37
+ def test_build_mappings_emits_stem_mdc_for_remaining_claude_rules(
38
+ tmp_path: Path,
39
+ ) -> None:
40
+ claude = tmp_path / ".claude"
41
+ _write_minimal_curated_rules(claude / "rules")
42
+ (claude / "docs").mkdir(parents=True, exist_ok=True)
43
+ (claude / "rules" / "plain-language.md").write_text(
44
+ "# Plain language\n\nBe brief.\n",
45
+ encoding="utf-8",
46
+ )
47
+ (claude / "rules" / "CLAUDE.md").write_text(
48
+ "# Package inventory\n", encoding="utf-8"
49
+ )
50
+ (claude / "rules" / "AGENTS.md").write_text("# Agent inventory\n", encoding="utf-8")
51
+ mappings = build_mappings(claude)
52
+ output_by_key = {each_mapping.key: each_mapping for each_mapping in mappings}
53
+ discovered = output_by_key["plain-language"]
54
+ assert discovered.output_name == "plain-language.mdc"
55
+ assert discovered.always_apply is True
56
+ assert discovered.description == "Plain language"
57
+ assert "CLAUDE.md" not in {
58
+ each_source.name
59
+ for each_mapping in mappings
60
+ for each_source in each_mapping.sources
61
+ }
62
+ assert "AGENTS.md" not in {
63
+ each_source.name
64
+ for each_mapping in mappings
65
+ for each_source in each_mapping.sources
66
+ }
67
+
68
+
69
+ def test_every_shipped_claude_rule_maps_to_an_mdc() -> None:
70
+ mappings = build_mappings(_PACKAGE_ROOT)
71
+ output_name_by_rule_file = {}
72
+ for each_mapping in mappings:
73
+ for each_source in each_mapping.sources:
74
+ if each_source.parent.name == "rules" and each_source.suffix == ".md":
75
+ output_name_by_rule_file[each_source.name] = each_mapping.output_name
76
+ for each_rule_file in sorted((_PACKAGE_ROOT / "rules").glob("*.md")):
77
+ if each_rule_file.name in _SKIPPED_RULE_FILE_NAMES:
78
+ continue
79
+ assert each_rule_file.name in output_name_by_rule_file, each_rule_file.name
@@ -1,5 +0,0 @@
1
- """Shared configuration for the sync-to-cursor package."""
2
-
3
- GENERATOR_VERSION: str = "1.2.3"
4
- CANONICAL_DOC_FILES: tuple[str, ...] = ("CODE_RULES.md", "TEST_QUALITY.md")
5
- MAX_RULE_BODY_LINES: int = 50