claude-dev-env 2.14.1 → 2.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/_shared/advisor/scripts/tier_model_ids.py +6 -2
- package/bin/AGENTS.md +6 -4
- package/bin/install-constants.mjs +51 -0
- package/bin/install.codex-rules.test.mjs +173 -0
- package/bin/install.cursor-rules.test.mjs +103 -0
- package/bin/install.mjs +165 -51
- package/bin/install.profile-root.test.mjs +8 -0
- package/bin/install.prune.test.mjs +2 -1
- package/bin/install.test.mjs +42 -8
- package/bin/install.transaction.test.mjs +1 -0
- package/bin/install.uninstall-transaction.test.mjs +1 -0
- package/bin/resolve-install-root.mjs +43 -10
- package/codex-rules/claude-dev-env.rules +12 -0
- package/package.json +3 -2
- package/scripts/AGENTS.md +0 -7
- package/scripts/claude_chain_runner.py +49 -2
- package/scripts/invoke_code_review.py +48 -51
- package/scripts/resolve_worker_spawn.py +42 -47
- package/scripts/sync_to_cursor/AGENTS.md +3 -3
- package/scripts/sync_to_cursor/canonical_docs.py +11 -11
- package/scripts/sync_to_cursor/config/__init__.py +8 -0
- package/scripts/sync_to_cursor/engine.py +26 -1
- package/scripts/sync_to_cursor/rules.py +76 -5
- package/scripts/test_claude_chain_runner.py +28 -0
- package/scripts/test_dispatcher_profile_import.py +184 -0
- package/scripts/test_resolve_worker_spawn.py +119 -1
- package/scripts/test_validate_instruction_pairs.py +30 -0
- package/scripts/tests/AGENTS.md +2 -0
- package/scripts/tests/test_engine.py +102 -0
- package/scripts/tests/test_rules.py +79 -0
- package/scripts/profile-isolation-launchers/config/mcp-bundles.json +0 -25
- package/scripts/profile-isolation-launchers/config/profile-isolation-constants.mjs +0 -60
- package/scripts/profile-isolation-launchers/config/profiles.manifest.json +0 -54
- package/scripts/profile-isolation-launchers/config/shared-allowlist.json +0 -64
- package/scripts/profile-isolation-launchers/launcher-runtime.mjs +0 -180
- package/scripts/profile-isolation-launchers/lib/profile-manifest.mjs +0 -288
- package/scripts/profile-isolation-launchers/mcp-bundles.mjs +0 -275
- package/scripts/profile-isolation-launchers/profile-isolation-contract.test.mjs +0 -221
- package/scripts/profile-isolation-launchers/tests/launcher-runtime.test.mjs +0 -108
- package/scripts/profile-isolation-launchers/tests/mcp-bundles.test.mjs +0 -147
- package/scripts/profile-isolation-launchers/tests/shortcut-contract.test.ps1 +0 -102
- package/scripts/profile-isolation-launchers/tests/version-compatibility.test.mjs +0 -210
- package/scripts/profile-isolation-launchers/version-compatibility.mjs +0 -299
- package/scripts/profile-isolation-launchers/windows/shortcut-inventory.ps1 +0 -127
- package/scripts/profile-isolation-launchers/windows/shortcut-manifest.json +0 -51
- package/scripts/profile-isolation-launchers/windows/shortcut-reconcile.ps1 +0 -77
- package/scripts/sync_to_cursor/config.py +0 -5
package/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
|
-
|
|
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
|
-
|
|
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}
|
|
2364
|
+
* @param {boolean} isManifestRequired
|
|
2226
2365
|
* @returns {ReturnType<typeof buildUninstallPlan>}
|
|
2227
2366
|
*/
|
|
2228
|
-
function resolveUninstallPlan(
|
|
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 ${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
*
|
|
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({
|
|
2336
|
-
const plan = resolveUninstallPlan(
|
|
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
|
|
2483
|
-
*
|
|
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
|
-
|
|
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
|
|
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 (
|
|
2583
|
-
console.error(`ERROR: failed to spawn install child: ${
|
|
2696
|
+
if (childProcess.error) {
|
|
2697
|
+
console.error(`ERROR: failed to spawn install child: ${childProcess.error.message}`);
|
|
2584
2698
|
return 1;
|
|
2585
2699
|
}
|
|
2586
|
-
return
|
|
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
|
|
1231
|
+
'the uninstall removes the mypy configuration the install writes outside ~/.claude',
|
|
1231
1232
|
);
|
|
1232
1233
|
assert.equal(
|
|
1233
1234
|
installerOutput.includes(`skipping ${mypyIniPath}`),
|
package/bin/install.test.mjs
CHANGED
|
@@ -1281,25 +1281,59 @@ const README_BASENAME_PATTERN = /^readme\.md$/i;
|
|
|
1281
1281
|
|
|
1282
1282
|
|
|
1283
1283
|
/**
|
|
1284
|
-
* Build a sandbox holding
|
|
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
|
|
1287
|
-
* @
|
|
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
|
|
1292
|
+
const installedRoot = join(root, managedRootName);
|
|
1292
1293
|
const backupRoot = join(root, 'pruned', 'run-timestamp');
|
|
1293
|
-
mkdirSync(
|
|
1294
|
+
mkdirSync(installedRoot, { recursive: true });
|
|
1294
1295
|
for (const [relativePath, contents] of Object.entries(installedFiles)) {
|
|
1295
|
-
const targetPath = join(
|
|
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 {
|
|
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
|
|
107
|
-
*
|
|
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
|
-
|
|
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
|
|
134
|
-
if (!
|
|
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
|
|
170
|
+
return targetPath;
|
|
138
171
|
}
|
|
139
172
|
if (token.startsWith('--target=')) {
|
|
140
|
-
const
|
|
141
|
-
if (!
|
|
173
|
+
const targetPath = token.slice('--target='.length);
|
|
174
|
+
if (!targetPath) {
|
|
142
175
|
throw new Error('--target requires a path argument');
|
|
143
176
|
}
|
|
144
|
-
return
|
|
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.
|
|
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\" \"
|
|
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__/**",
|