Files
ECC/scripts/lib/install/claude-skill-migration.js
T
Affaan MustafaandGitHub f3afd59045 fix: flatten Claude skill installs (#2582)
Flatten managed Claude skill destinations, preserve user-owned conflicts, and migrate legacy nested installs through the lifecycle tooling.
2026-07-26 03:20:06 -07:00

416 lines
12 KiB
JavaScript

'use strict';
const fs = require('fs');
const path = require('path');
const { readInstallState } = require('../install-state');
const { assertWithinTrustedRoot } = require('../path-safety');
const CLAUDE_TARGETS = new Set(['claude', 'claude-project']);
function pathExists(filePath) {
try {
fs.lstatSync(filePath);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
function normalizeSourceRelativePath(sourceRelativePath) {
const slashNormalized = String(sourceRelativePath || '').replace(/\\/g, '/');
const normalized = path.posix.normalize(slashNormalized).replace(/^\.\//, '');
if (
!normalized
|| normalized === '.'
|| normalized === '..'
|| normalized.startsWith('../')
|| path.posix.isAbsolute(normalized)
) {
return null;
}
return normalized;
}
function comparablePath(filePath) {
const resolvedPath = path.resolve(filePath);
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
}
function samePath(leftPath, rightPath) {
return comparablePath(leftPath) === comparablePath(rightPath);
}
function assertSafeSkillPath(targetPath, targetRoot, action) {
const resolvedRoot = path.resolve(targetRoot);
const resolvedTarget = path.resolve(targetPath);
const relativePath = path.relative(resolvedRoot, resolvedTarget);
if (
relativePath === ''
|| relativePath.startsWith('..')
|| path.isAbsolute(relativePath)
) {
throw new Error(
`Refusing to ${action} outside the install root: '${targetPath}' is not within '${targetRoot}'.`
);
}
let currentPath = resolvedRoot;
for (const segment of relativePath.split(path.sep)) {
currentPath = path.join(currentPath, segment);
let stats;
try {
stats = fs.lstatSync(currentPath);
} catch (error) {
if (error && error.code === 'ENOENT') {
break;
}
throw error;
}
if (stats.isSymbolicLink()) {
throw new Error(
`Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.`
);
}
}
if (pathExists(targetRoot)) {
assertWithinTrustedRoot(targetPath, targetRoot, action);
}
}
function describeClaudeSkillOperation(targetRoot, operation) {
if (!operation || operation.kind !== 'copy-file') {
return null;
}
const sourceRelativePath = normalizeSourceRelativePath(operation.sourceRelativePath);
if (!sourceRelativePath) {
return null;
}
const sourceParts = sourceRelativePath.split('/');
if (sourceParts[0] !== 'skills' || sourceParts.length < 3 || !sourceParts[1]) {
return null;
}
const skillName = sourceParts[1];
const relativeParts = sourceParts.slice(2);
const flatSkillRoot = path.join(targetRoot, 'skills', skillName);
const legacySkillRoot = path.join(targetRoot, 'skills', 'ecc', skillName);
return {
sourceKey: sourceRelativePath,
skillName,
flatSkillRoot,
flatDestinationPath: path.join(flatSkillRoot, ...relativeParts),
legacySkillRoot,
legacyDestinationPath: path.join(legacySkillRoot, ...relativeParts),
};
}
function assertSafeClaudeSkillOperation(plan, operation) {
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return;
}
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
return;
}
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'install Claude skill'
);
}
function isManagedOperation(operation) {
return operation && operation.ownership === 'managed';
}
function uniqueOperations(operations) {
const seen = new Set();
return operations.filter(operation => {
const key = [
operation.kind,
normalizeSourceRelativePath(operation.sourceRelativePath) || operation.sourceRelativePath,
comparablePath(operation.destinationPath),
].join('\0');
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function buildState(statePreview, operations) {
return {
...statePreview,
operations: uniqueOperations(operations).map(operation => ({ ...operation })),
};
}
function groupCurrentSkillOperations(plan) {
const groups = new Map();
for (const operation of plan.operations) {
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
if (!descriptor || !samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
continue;
}
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'install Claude skill'
);
const current = groups.get(descriptor.flatSkillRoot) || [];
current.push({ operation, descriptor });
groups.set(descriptor.flatSkillRoot, current);
}
return groups;
}
function classifyPreviousOperations(plan, previousState) {
const flatByDestination = new Map();
const legacyBySource = new Map();
const legacyBySkillRoot = new Map();
for (const operation of (previousState && previousState.operations) || []) {
if (!isManagedOperation(operation)) {
continue;
}
const descriptor = describeClaudeSkillOperation(plan.targetRoot, operation);
if (!descriptor) {
continue;
}
if (samePath(operation.destinationPath, descriptor.flatDestinationPath)) {
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'inspect managed Claude skill'
);
flatByDestination.set(comparablePath(operation.destinationPath), operation);
continue;
}
if (!samePath(operation.destinationPath, descriptor.legacyDestinationPath)) {
continue;
}
assertSafeSkillPath(
operation.destinationPath,
plan.targetRoot,
'migrate managed Claude skill'
);
legacyBySource.set(descriptor.sourceKey, operation);
const current = legacyBySkillRoot.get(descriptor.legacySkillRoot) || [];
current.push({ operation, descriptor });
legacyBySkillRoot.set(descriptor.legacySkillRoot, current);
}
return {
flatByDestination,
legacyBySource,
legacyBySkillRoot,
};
}
function createConflictWarning(skillName, flatSkillRoot, retainsLegacy) {
const legacySuffix = retainsLegacy
? ' The existing ECC-managed nested copy was retained and remains tracked for uninstall.'
: '';
return `Skipped Claude skill '${skillName}' at ${flatSkillRoot}: the flat skill directory is user-owned because it is not recorded in ECC install-state.${legacySuffix}`;
}
function createFileConflictWarning(destinationPath, retainsLegacy) {
const legacySuffix = retainsLegacy
? ' The matching ECC-managed nested file was retained and remains tracked for uninstall.'
: '';
return `Skipped user-owned Claude skill file ${destinationPath}: the existing file is not recorded in ECC install-state.${legacySuffix}`;
}
function createDisabledMigration(plan) {
return {
enabled: false,
appliedOperations: [...plan.operations],
skippedOperations: [],
warnings: [],
bridgeState: plan.statePreview,
finalState: plan.statePreview,
legacyOperationsToRemove: [],
requiresBridgeState: false,
};
}
function collectRetainedLegacyOperations(currentGroups, previous) {
const currentSourceKeys = new Set(
[...currentGroups.values()]
.flat()
.map(({ descriptor }) => descriptor.sourceKey)
);
return (
[...previous.legacyBySource.entries()]
.filter(([sourceKey]) => !currentSourceKeys.has(sourceKey))
.map(([_sourceKey, operation]) => operation)
);
}
function classifySkillGroup(flatSkillRoot, entries, previous) {
const hasManagedFlatFile = entries.some(({ operation }) => (
previous.flatByDestination.has(comparablePath(operation.destinationPath))
));
const legacyEntries = previous.legacyBySkillRoot.get(
entries[0].descriptor.legacySkillRoot
) || [];
if (pathExists(flatSkillRoot) && !hasManagedFlatFile) {
return {
skippedOperations: entries.map(({ operation }) => operation),
warnings: [createConflictWarning(
entries[0].descriptor.skillName,
flatSkillRoot,
legacyEntries.length > 0
)],
retainedLegacyOperations: legacyEntries.map(({ operation }) => operation),
};
}
const conflicts = entries.filter(({ operation }) => (
pathExists(operation.destinationPath)
&& !previous.flatByDestination.has(comparablePath(operation.destinationPath))
));
return {
skippedOperations: conflicts.map(({ operation }) => operation),
warnings: conflicts.map(({ operation, descriptor }) => createFileConflictWarning(
operation.destinationPath,
previous.legacyBySource.has(descriptor.sourceKey)
)),
retainedLegacyOperations: conflicts
.map(({ descriptor }) => previous.legacyBySource.get(descriptor.sourceKey))
.filter(Boolean),
};
}
function classifySkillConflicts(currentGroups, previous) {
const groupClassifications = [...currentGroups.entries()]
.map(([flatSkillRoot, entries]) => classifySkillGroup(
flatSkillRoot,
entries,
previous
));
const skippedOperations = groupClassifications
.flatMap(classification => classification.skippedOperations);
return {
skippedOperations,
skippedDestinations: new Set(
skippedOperations.map(operation => comparablePath(operation.destinationPath))
),
warnings: groupClassifications.flatMap(classification => classification.warnings),
retainedLegacyOperations: new Set([
...collectRetainedLegacyOperations(currentGroups, previous),
...groupClassifications.flatMap(
classification => classification.retainedLegacyOperations
),
]),
};
}
function buildMigrationStates(plan, previousState, previous, classification) {
const { skippedDestinations, retainedLegacyOperations } = classification;
const appliedOperations = plan.operations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
));
const legacyOperations = [...previous.legacyBySource.values()];
const legacyOperationsToRemove = legacyOperations.filter(operation => (
!retainedLegacyOperations.has(operation)
));
const finalOperations = [
...plan.statePreview.operations.filter(operation => (
!skippedDestinations.has(comparablePath(operation.destinationPath))
)),
...retainedLegacyOperations,
];
const bridgeOperations = [
...((previousState && previousState.operations) || []),
...appliedOperations,
];
return {
appliedOperations,
bridgeState: buildState(plan.statePreview, bridgeOperations),
finalState: buildState(plan.statePreview, finalOperations),
legacyOperationsToRemove,
requiresBridgeState: appliedOperations.length > 0,
};
}
function prepareClaudeSkillMigration(plan) {
const target = plan && plan.adapter && plan.adapter.target;
if (!CLAUDE_TARGETS.has(target)) {
return createDisabledMigration(plan);
}
const previousState = pathExists(plan.installStatePath)
? readInstallState(plan.installStatePath)
: null;
const currentGroups = groupCurrentSkillOperations(plan);
const previous = classifyPreviousOperations(plan, previousState);
const classification = classifySkillConflicts(currentGroups, previous);
const states = buildMigrationStates(
plan,
previousState,
previous,
classification
);
return {
enabled: true,
appliedOperations: states.appliedOperations,
skippedOperations: classification.skippedOperations,
warnings: classification.warnings,
bridgeState: states.bridgeState,
finalState: states.finalState,
legacyOperationsToRemove: states.legacyOperationsToRemove,
requiresBridgeState: states.requiresBridgeState,
};
}
function cleanupEmptyLegacyParents(filePath, targetRoot) {
const skillsRoot = path.join(targetRoot, 'skills');
let currentPath = path.dirname(filePath);
while (!samePath(currentPath, skillsRoot)) {
assertSafeSkillPath(currentPath, targetRoot, 'clean Claude skill migration');
if (!pathExists(currentPath) || fs.readdirSync(currentPath).length > 0) {
return;
}
fs.rmdirSync(currentPath);
currentPath = path.dirname(currentPath);
}
}
function removeLegacyClaudeSkillFiles(migration, targetRoot) {
for (const operation of migration.legacyOperationsToRemove) {
assertSafeSkillPath(
operation.destinationPath,
targetRoot,
'migrate managed Claude skill'
);
fs.rmSync(operation.destinationPath, { force: true });
cleanupEmptyLegacyParents(operation.destinationPath, targetRoot);
}
}
module.exports = {
assertSafeClaudeSkillOperation,
prepareClaudeSkillMigration,
removeLegacyClaudeSkillFiles,
};