'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, };