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/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,
@@ -110,6 +114,35 @@ const RETIRED_SKILL_REASON_LABEL = 'retired';
110
114
  const STALE_FILE_REASON_LABEL = 'stale';
111
115
  const MANIFEST_MANAGED_PERMISSIONS_KEY = 'managedPermissions';
112
116
 
117
+ /**
118
+ * Managed-home-relative directories this package leaves to whoever deployed
119
+ * them: it writes nothing inside them and prunes nothing out of them.
120
+ *
121
+ * `scripts/profile-isolation-launchers` is the live CLI profile launcher. Its
122
+ * executable modules are deployed from a separate source, so a payload covering
123
+ * only part of that tree left config and code out of step. This package stopped
124
+ * shipping its part, and the stale-file prune reads a path the package no longer
125
+ * writes as stale, which would move the live files aside on the next install.
126
+ * Naming the directory here keeps both halves of the tree with their owner.
127
+ */
128
+ const RETAINED_UNMANAGED_RELATIVE_PATHS = [
129
+ 'scripts/profile-isolation-launchers',
130
+ ];
131
+
132
+ /**
133
+ * Report whether a path a prior manifest recorded sits inside a directory this
134
+ * package leaves to another owner.
135
+ *
136
+ * @param {string} candidatePath The absolute path the prior manifest recorded.
137
+ * @param {string} managedHomeDirectory The managed home the relative names resolve against.
138
+ * @returns {boolean} True when the path sits inside a retained unmanaged directory.
139
+ */
140
+ function isRetainedUnmanagedPath(candidatePath, managedHomeDirectory) {
141
+ return RETAINED_UNMANAGED_RELATIVE_PATHS.some(
142
+ relativePath => isInsideDirectory(candidatePath, join(managedHomeDirectory, relativePath)),
143
+ );
144
+ }
145
+
113
146
  export const CORE_INCLUDE_DIRECTORIES = [
114
147
  'rules', 'docs', 'commands', 'agents', 'audit-rubrics', '_shared', 'scripts',
115
148
  ];
@@ -211,7 +244,7 @@ function discoverDependencyGroups() {
211
244
  readFileSync(join(dependencyRoot, 'package.json'), 'utf8')
212
245
  );
213
246
  const groupName = dependencyPackageJson.claudeDevEnv?.groupName
214
- || dependencyName.replace(/^@[^/]+\//, '');
247
+ || dependencyName.replace(new RegExp('^@[^/]+/'), '');
215
248
  const group = {
216
249
  description: dependencyPackageJson.description || dependencyName,
217
250
  packageRoot: dependencyRoot,
@@ -257,6 +290,7 @@ export const INSTALL_GROUPS = {
257
290
  skills: CORE_SKILLS,
258
291
  includeDirectories: CORE_INCLUDE_DIRECTORIES,
259
292
  includeAllHooks: true,
293
+ includeCodexRules: true,
260
294
  },
261
295
  journal: {
262
296
  description: 'Session logging and memory',
@@ -302,6 +336,75 @@ export function isWindowsStorePythonStub(executablePath) {
302
336
  return /[\\/]windowsapps[\\/]/i.test(executablePath);
303
337
  }
304
338
 
339
+ /**
340
+ * Split a stored Python command into an executable and prefix arguments.
341
+ *
342
+ * Args:
343
+ * pythonCommand: The command the installer detected (`py -3`, `python3`, or a path).
344
+ *
345
+ * Returns:
346
+ * `{ file, prefixArguments }` for `execFileSync`.
347
+ */
348
+ export function pythonFileAndPrefixArguments(pythonCommand) {
349
+ if (pythonCommand === WINDOWS_PYTHON_LAUNCHER_COMMAND) {
350
+ return { file: 'py', prefixArguments: ['-3'] };
351
+ }
352
+ const unquoted = pythonCommand.replace(/^"(.*)"$/, '$1');
353
+ return { file: unquoted, prefixArguments: [] };
354
+ }
355
+
356
+ /**
357
+ * Read generated Cursor paths from the sync manifest under a Cursor home.
358
+ *
359
+ * Args:
360
+ * cursorRoot: Absolute `.cursor` directory.
361
+ *
362
+ * Returns:
363
+ * Absolute paths the installer may record, including the sync manifest.
364
+ */
365
+ export function collectManagedCursorSyncPaths(cursorRoot) {
366
+ const manifestPath = join(cursorRoot, '.sync-manifest.json');
367
+ if (!existsSync(manifestPath)) return [];
368
+ const parsed = JSON.parse(readFileSync(manifestPath, 'utf8'));
369
+ const generatedPaths = [manifestPath];
370
+ const allEntries = { ...(parsed.entries || {}), ...(parsed.docs_entries || {}) };
371
+ for (const eachRelativePath of Object.keys(allEntries)) {
372
+ generatedPaths.push(join(cursorRoot, eachRelativePath));
373
+ }
374
+ return generatedPaths;
375
+ }
376
+
377
+ /**
378
+ * Run the installed Cursor rule generator against Claude and Cursor roots.
379
+ *
380
+ * Args:
381
+ * pythonCommand: Interpreter command from install preflight.
382
+ * scriptPath: Absolute `sync_to_cursor.py` path.
383
+ * claudeRoot: Managed Claude root that holds `rules/` and `docs/`.
384
+ * cursorRoot: Cursor home that receives `rules/*.mdc`.
385
+ *
386
+ * Returns:
387
+ * void
388
+ */
389
+ export function runCursorRuleSync(pythonCommand, scriptPath, claudeRoot, cursorRoot) {
390
+ mkdirSync(cursorRoot, { recursive: true });
391
+ const { file, prefixArguments } = pythonFileAndPrefixArguments(pythonCommand);
392
+ execFileSync(
393
+ file,
394
+ [
395
+ ...prefixArguments,
396
+ scriptPath,
397
+ '--force',
398
+ '--quiet',
399
+ '--claude-root',
400
+ claudeRoot,
401
+ '--cursor-root',
402
+ cursorRoot,
403
+ ],
404
+ { stdio: 'inherit' },
405
+ );
406
+ }
407
+
305
408
  /**
306
409
  * Formats an absolute interpreter path as a settings.json hook command prefix:
307
410
  * forward-slash separators, double-quoted when the path contains a space so the
@@ -629,8 +732,7 @@ function isManagedPath(candidatePath, managedHomeDirectory = CLAUDE_HOME) {
629
732
  * @returns {boolean} True when the installer itself writes the path.
630
733
  */
631
734
  function isRemovableManifestRecord(candidatePath) {
632
- if (isManagedPath(candidatePath)) return true;
633
- return comparisonKeyForPath(candidatePath) === comparisonKeyForPath(MYPY_INI_INSTALL_PATH);
735
+ return isAllowedInstallDestination(candidatePath, INSTALL_ROOT_RESOLUTION);
634
736
  }
635
737
 
636
738
  /**
@@ -650,6 +752,10 @@ function owningManagedRoot(installedFilePath) {
650
752
  const managedRoot = join(CLAUDE_HOME, directoryName);
651
753
  if (isInsideDirectory(resolvedPath, managedRoot)) return managedRoot;
652
754
  }
755
+ const codexRulesDirectory = INSTALL_ROOT_RESOLUTION.codexRulesInstallDirectory;
756
+ if (isInsideDirectory(resolvedPath, codexRulesDirectory)) return codexRulesDirectory;
757
+ const cursorInstallDirectory = INSTALL_ROOT_RESOLUTION.cursorInstallDirectory;
758
+ if (isInsideDirectory(resolvedPath, cursorInstallDirectory)) return cursorInstallDirectory;
653
759
  return null;
654
760
  }
655
761
 
@@ -755,6 +861,7 @@ export function pruneStaleInstalledFiles(
755
861
  const stalePath = resolve(priorFile);
756
862
  if (!isInsideDirectory(stalePath, resolvedRoot)) continue;
757
863
  if (currentFileKeys.has(comparisonKeyForPath(stalePath, options))) continue;
864
+ if (isRetainedUnmanagedPath(stalePath, managedHomeDirectory)) continue;
758
865
  if (!isMovableStaleFile(stalePath)) continue;
759
866
  const backupRelativePath = relative(resolvedRoot, stalePath);
760
867
  const didMove = moveIntoRunBackup(
@@ -1856,7 +1963,7 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
1856
1963
  `${PACKAGE_NAME}: --update — removing prior managed files under ${CLAUDE_HOME}, then reinstalling from the package.\n`,
1857
1964
  );
1858
1965
  purgeManagedInstallation({
1859
- requireManifest: false,
1966
+ isManifestRequired: false,
1860
1967
  throwIfFault,
1861
1968
  });
1862
1969
  } else if (isUpdateRefresh) {
@@ -1940,6 +2047,30 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
1940
2047
  }
1941
2048
  }
1942
2049
  }
2050
+ const shouldInstallCodexRules = !selectedGroups
2051
+ || activeGroups.some((eachGroup) => eachGroup.includeCodexRules);
2052
+ if (shouldInstallCodexRules) {
2053
+ const sourceDirectory = join(PACKAGE_ROOT, CODEX_RULES_PACKAGE_DIRECTORY_NAME);
2054
+ if (existsSync(sourceDirectory)) {
2055
+ const destinationDirectory = INSTALL_ROOT_RESOLUTION.codexRulesInstallDirectory;
2056
+ const stats = copyTree(sourceDirectory, destinationDirectory);
2057
+ summary.codexRules = stats;
2058
+ allInstalledFiles.push(...stats.paths);
2059
+ }
2060
+ }
2061
+ const shouldInstallCursorRules = !selectedGroups
2062
+ || activeGroups.some((eachGroup) => (eachGroup.includeDirectories || []).includes('rules'));
2063
+ if (shouldInstallCursorRules) {
2064
+ const scriptPath = join(CLAUDE_HOME, 'scripts', CURSOR_SYNC_SCRIPT_FILE_NAME);
2065
+ if (!existsSync(scriptPath)) {
2066
+ throw new Error(`cursor rule sync script missing: ${scriptPath}`);
2067
+ }
2068
+ const cursorRoot = dirname(INSTALL_ROOT_RESOLUTION.cursorRulesInstallDirectory);
2069
+ runCursorRuleSync(pythonCommand, scriptPath, CLAUDE_HOME, cursorRoot);
2070
+ const generatedCursorPaths = collectManagedCursorSyncPaths(cursorRoot);
2071
+ allInstalledFiles.push(...generatedCursorPaths);
2072
+ summary.cursorRules = { created: generatedCursorPaths.length, updated: 0, paths: generatedCursorPaths };
2073
+ }
1943
2074
  let skillsCreated = 0;
1944
2075
  let skillsUpdated = 0;
1945
2076
  const skillPaths = [];
@@ -2136,6 +2267,14 @@ function executeInstallPlanMutations(plan, transactionHelpers) {
2136
2267
  console.log(` ${directory}: ${created + updated} files (${created} new, ${updated} updated)`);
2137
2268
  }
2138
2269
  }
2270
+ if (summary.codexRules) {
2271
+ const { created, updated } = summary.codexRules;
2272
+ console.log(` ${CODEX_RULES_PACKAGE_DIRECTORY_NAME}: ${created + updated} files (${created} new, ${updated} updated)`);
2273
+ }
2274
+ if (summary.cursorRules) {
2275
+ const { created } = summary.cursorRules;
2276
+ console.log(` cursor-rules: ${created} generated files`);
2277
+ }
2139
2278
  if (summary.skills) {
2140
2279
  const { created, updated, pruned } = summary.skills;
2141
2280
  const staleClause = pruned > 0 ? `, ${pruned} stale moved aside` : '';
@@ -2222,14 +2361,14 @@ function removeRecordedFile(filePath) {
2222
2361
  /**
2223
2362
  * Build the uninstall plan for this managed root.
2224
2363
  *
2225
- * @param {boolean} requireManifest
2364
+ * @param {boolean} isManifestRequired
2226
2365
  * @returns {ReturnType<typeof buildUninstallPlan>}
2227
2366
  */
2228
- function resolveUninstallPlan(requireManifest) {
2367
+ function resolveUninstallPlan(isManifestRequired) {
2229
2368
  return buildUninstallPlan({
2230
2369
  managedRoot: CLAUDE_HOME,
2231
2370
  manifestFilePath: MANIFEST_FILE,
2232
- requireManifest,
2371
+ requireManifest: isManifestRequired,
2233
2372
  isRemovableRecord: isRemovableManifestRecord,
2234
2373
  });
2235
2374
  }
@@ -2266,20 +2405,20 @@ function executeUninstallPlan(plan, helpers = {}) {
2266
2405
  }
2267
2406
  if (plan.skippedFiles.length > 0) {
2268
2407
  console.warn(
2269
- ` ${plan.skippedFiles.length} manifest record(s) skipped — each names a path outside ${CLAUDE_HOME} and outside ${MYPY_INI_INSTALL_PATH}`,
2408
+ ` ${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
2409
  );
2271
2410
  }
2272
2411
  throwIfFault(FAULT_PHASES.AFTER_FILE_STAGING);
2273
2412
 
2274
2413
  if (existsSync(plan.settingsPath)) {
2275
2414
  const settings = JSON.parse(readFileSync(plan.settingsPath, 'utf8'));
2276
- let settingsChanged = false;
2415
+ let didSettingsChange = false;
2277
2416
  if (settings.hooks) {
2278
2417
  const managedHookRelativePaths = managedHookScriptRelativePathsFromSourceRoots(
2279
2418
  managedPackageSourceRoots(),
2280
2419
  );
2281
2420
  pruneManagedHooksFromSettings(settings, managedHookRelativePaths);
2282
- settingsChanged = true;
2421
+ didSettingsChange = true;
2283
2422
  console.log(' Hook entries removed from settings.json');
2284
2423
  }
2285
2424
  const managedDenyFromPlan = plan.managedPermissionDenyEntries.length > 0
@@ -2288,13 +2427,13 @@ function executeUninstallPlan(plan, helpers = {}) {
2288
2427
  if (managedDenyFromPlan.length > 0) {
2289
2428
  const pruneOutcome = pruneManagedPermissionsFromSettings(settings, managedDenyFromPlan);
2290
2429
  if (pruneOutcome.removedCount > 0) {
2291
- settingsChanged = true;
2430
+ didSettingsChange = true;
2292
2431
  console.log(
2293
2432
  ` Permission entries removed from settings.json: ${pruneOutcome.removedCount} managed deny(s)`,
2294
2433
  );
2295
2434
  }
2296
2435
  }
2297
- if (settingsChanged) {
2436
+ if (didSettingsChange) {
2298
2437
  writeFileSync(plan.settingsPath, JSON.stringify(settings, null, 4) + '\n');
2299
2438
  }
2300
2439
  }
@@ -2327,13 +2466,13 @@ function executeUninstallPlan(plan, helpers = {}) {
2327
2466
  * without nesting a second journal.
2328
2467
  *
2329
2468
  * @param {{
2330
- * requireManifest: boolean,
2469
+ * isManifestRequired: boolean,
2331
2470
  * throwIfFault?: (phase: string) => void,
2332
2471
  * }} options
2333
2472
  * @returns {number|void} 0 when no manifest exists and none is required.
2334
2473
  */
2335
- function purgeManagedInstallation({ requireManifest, throwIfFault }) {
2336
- const plan = resolveUninstallPlan(requireManifest);
2474
+ function purgeManagedInstallation({ isManifestRequired, throwIfFault }) {
2475
+ const plan = resolveUninstallPlan(isManifestRequired);
2337
2476
  if (plan.isNoOp) {
2338
2477
  return 0;
2339
2478
  }
@@ -2433,6 +2572,8 @@ Examples:
2433
2572
 
2434
2573
  Install location: ~/.claude/ by default; CLAUDE_CONFIG_DIR or --target selects another managed root.
2435
2574
  Named profiles resolve under LLM_SETTINGS_PROFILES_ROOT or ~/.claude-profiles/<directoryName>.
2575
+ Codex exec-policy files copy into ~/.codex/rules, or CODEX_HOME/rules when CODEX_HOME is set.
2576
+ Cursor rule files generate into ~/.cursor/rules as stem-named mdc files, one per Claude rule.
2436
2577
 
2437
2578
  Root precedence: --target > CLAUDE_CONFIG_DIR > ~/.claude
2438
2579
  Profile selection (--profile/--profiles) is mutually exclusive with --target.
@@ -2479,13 +2620,15 @@ function realPathOrSelf(filesystemPath) {
2479
2620
  }
2480
2621
 
2481
2622
  /**
2482
- * Load profile id → directoryName from the A1 launcher contract when present.
2483
- * Falls back to identity mapping when the file is missing, unreadable, or empty.
2623
+ * Load profile id → directoryName for the install targets this package resolves.
2624
+ *
2625
+ * Each profile's directory carries the profile's own name, so the map is an
2626
+ * identity over the ids a multi-target install accepts.
2484
2627
  *
2485
2628
  * @returns {Record<string, string>}
2486
2629
  */
2487
2630
  function loadDirectoryNameByProfileId() {
2488
- const fallbackDirectoryNameByProfileId = {
2631
+ return {
2489
2632
  main: 'main',
2490
2633
  editor: 'editor',
2491
2634
  mel: 'mel',
@@ -2493,35 +2636,6 @@ function loadDirectoryNameByProfileId() {
2493
2636
  master: 'master',
2494
2637
  kimi: 'kimi',
2495
2638
  };
2496
- const manifestPath = join(
2497
- PACKAGE_ROOT,
2498
- 'scripts',
2499
- 'profile-isolation-launchers',
2500
- 'config',
2501
- 'profiles.manifest.json',
2502
- );
2503
- if (!existsSync(manifestPath)) {
2504
- return fallbackDirectoryNameByProfileId;
2505
- }
2506
- try {
2507
- const document = JSON.parse(readFileSync(manifestPath, 'utf8'));
2508
- /** @type {Record<string, string>} */
2509
- const directoryNameByProfileId = {};
2510
- const profiles = document && typeof document === 'object' ? document.profiles : null;
2511
- if (profiles && typeof profiles === 'object') {
2512
- for (const [eachProfileId, eachProfile] of Object.entries(profiles)) {
2513
- if (eachProfile && typeof eachProfile === 'object' && typeof eachProfile.directoryName === 'string') {
2514
- directoryNameByProfileId[eachProfileId] = eachProfile.directoryName;
2515
- }
2516
- }
2517
- }
2518
- if (Object.keys(directoryNameByProfileId).length === 0) {
2519
- return fallbackDirectoryNameByProfileId;
2520
- }
2521
- return directoryNameByProfileId;
2522
- } catch {
2523
- return fallbackDirectoryNameByProfileId;
2524
- }
2525
2639
  }
2526
2640
 
2527
2641
  /**
@@ -2564,7 +2678,7 @@ function runInstallForAllTargets(allTargets, childArgv) {
2564
2678
  * @returns {number | null}
2565
2679
  */
2566
2680
  function spawnInstallChild(target, childArgv) {
2567
- const result = spawnSync(
2681
+ const childProcess = spawnSync(
2568
2682
  process.execPath,
2569
2683
  [
2570
2684
  fileURLToPath(import.meta.url),
@@ -2579,11 +2693,11 @@ function spawnInstallChild(target, childArgv) {
2579
2693
  env: process.env,
2580
2694
  },
2581
2695
  );
2582
- if (result.error) {
2583
- console.error(`ERROR: failed to spawn install child: ${result.error.message}`);
2696
+ if (childProcess.error) {
2697
+ console.error(`ERROR: failed to spawn install child: ${childProcess.error.message}`);
2584
2698
  return 1;
2585
2699
  }
2586
- return result.status === null ? 1 : result.status;
2700
+ return childProcess.status === null ? 1 : childProcess.status;
2587
2701
  }
2588
2702
 
2589
2703
  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}`),
@@ -1281,25 +1281,59 @@ const README_BASENAME_PATTERN = /^readme\.md$/i;
1281
1281
 
1282
1282
 
1283
1283
  /**
1284
- * Build a sandbox holding an installed skills root and a run backup root.
1284
+ * Build a sandbox holding one installed managed root and a run backup root.
1285
1285
  *
1286
- * @param {object} installedFiles Forward-slash relative paths under the skills root mapped to contents.
1287
- * @returns {{root: string, skillsRoot: string, backupRoot: string}} The sandbox paths.
1286
+ * @param {object} installedFiles Forward-slash relative paths under the installed root mapped to contents.
1287
+ * @param {string} [managedRootName] The managed top-level directory the files sit under.
1288
+ * @returns {{root: string, skillsRoot: string, installedRoot: string, backupRoot: string}} The sandbox paths.
1288
1289
  */
1289
- function createStalePruneSandbox(installedFiles) {
1290
+ function createStalePruneSandbox(installedFiles, managedRootName = 'skills') {
1290
1291
  const root = mkdtempSync(join(tmpdir(), 'cdev-stale-prune-'));
1291
- const skillsRoot = join(root, 'skills');
1292
+ const installedRoot = join(root, managedRootName);
1292
1293
  const backupRoot = join(root, 'pruned', 'run-timestamp');
1293
- mkdirSync(skillsRoot, { recursive: true });
1294
+ mkdirSync(installedRoot, { recursive: true });
1294
1295
  for (const [relativePath, contents] of Object.entries(installedFiles)) {
1295
- const targetPath = join(skillsRoot, relativePath);
1296
+ const targetPath = join(installedRoot, relativePath);
1296
1297
  mkdirSync(dirname(targetPath), { recursive: true });
1297
1298
  writeFileSync(targetPath, contents);
1298
1299
  }
1299
- return { root, skillsRoot, backupRoot };
1300
+ return { root, skillsRoot: installedRoot, installedRoot, backupRoot };
1300
1301
  }
1301
1302
 
1302
1303
 
1304
+ test('pruneStaleInstalledFiles leaves a retained unmanaged path the package stopped shipping in place', () => {
1305
+ const sandbox = createStalePruneSandbox({
1306
+ 'profile-isolation-launchers/config/mcp-bundles.json': '{"schemaVersion":1}\n',
1307
+ 'sync-to-cursor.py': 'print("shipped")\n',
1308
+ }, 'scripts');
1309
+ try {
1310
+ const shippedFilePath = join(sandbox.installedRoot, 'sync-to-cursor.py');
1311
+ const retainedFilePath = join(
1312
+ sandbox.installedRoot, 'profile-isolation-launchers', 'config', 'mcp-bundles.json',
1313
+ );
1314
+
1315
+ const pruneOutcome = pruneStaleInstalledFiles(
1316
+ [shippedFilePath, retainedFilePath],
1317
+ [shippedFilePath],
1318
+ sandbox.installedRoot,
1319
+ sandbox.backupRoot,
1320
+ { managedHomeDirectory: sandbox.root },
1321
+ );
1322
+
1323
+ assert.equal(pruneOutcome.prunedCount, 0, 'a retained unmanaged path never counts as pruned');
1324
+ assert.deepEqual(pruneOutcome.failedPaths, [], 'skipping a retained path reports no failed move');
1325
+ assert.equal(
1326
+ existsSync(retainedFilePath),
1327
+ true,
1328
+ 'the launcher file a prior install recorded stays where the live launcher reads it',
1329
+ );
1330
+ assert.equal(existsSync(shippedFilePath), true, 'a file this run wrote stays in place');
1331
+ } finally {
1332
+ rmSync(sandbox.root, { recursive: true, force: true });
1333
+ }
1334
+ });
1335
+
1336
+
1303
1337
  /**
1304
1338
  * Run a callable with console.warn captured, returning its value and the warnings.
1305
1339
  *
@@ -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.1",
4
4
  "description": "Claude Code development standards — rules, hooks, agents, commands, and skills",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  "codex-compat": "bin/codex-compat.mjs"
9
9
  },
10
10
  "scripts": {
11
- "test": "node --test \"bin/*.test.mjs\" \"skills/**/*.test.mjs\" \"scripts/profile-isolation-launchers/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\""
11
+ "test": "node --test \"bin/*.test.mjs\" \"skills/**/*.test.mjs\" \"tests/fresh-session/**/*.test.mjs\""
12
12
  },
13
13
  "files": [
14
14
  "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__/**",