'use strict'; const path = require('path'); const { ClaudeSetupError, CURRENT_PLUGIN_ID, OFFICIAL_MARKETPLACE_URL, VALID_HOOK_MODES, VALID_SCOPES, assertNoConflictingEccPlugins, assertSafeLocalInventory, currentEccPlugins, deriveHookMode, ensureOfficialMarketplace, ensurePluginAtScope, hookOptions, isOfficialMarketplace, needsClaudeCommitAttributionPreferenceWrite, parseMarketplaceList, parsePluginList, readSettings, readStoredHookOptions, runClaude, writeClaudePluginOptions, } = require('./claude-plugin-setup'); const { resolveClaudePaths } = require('./install/inventory'); function migrationError(code, message, details = {}) { return new ClaudeSetupError(code, message, details); } function recoveryCommands(sourceScope, destinationScope) { const commands = []; if (sourceScope) { commands.push( `claude plugin uninstall ${CURRENT_PLUGIN_ID} --scope ${sourceScope} --keep-data` ); } commands.push( `ecc setup --mode claude-plugin --scope ${destinationScope} --move-scope --yes` ); return commands; } function readPluginInventory(run, projectRoot, phase) { return parsePluginList( run( ['plugin', 'list', '--json'], { cwd: projectRoot, phase } ).stdout ); } function assertMigrationInventory(plugins, destinationScope) { assertNoConflictingEccPlugins(plugins); const installed = currentEccPlugins(plugins); const observedScopes = installed.map(plugin => plugin.scope); const uniqueScopes = new Set(observedScopes); if (installed.length === 0) { throw migrationError( 'PLUGIN_NOT_INSTALLED', `${CURRENT_PLUGIN_ID} is not installed, so there is no source scope to migrate.`, { observedScopes, recovery: [ `ecc setup --mode claude-plugin --scope ${destinationScope} --yes`, ], } ); } if ( installed.length > 2 || uniqueScopes.size !== installed.length || ( installed.length === 2 && !uniqueScopes.has(destinationScope) ) ) { throw migrationError( 'AMBIGUOUS_PLUGIN_SCOPES', `Cannot safely migrate ${CURRENT_PLUGIN_ID} from ambiguous scopes: ${observedScopes.join(', ')}.`, { observedScopes } ); } if (installed.length === 1 && installed[0].scope === destinationScope) { if (installed[0].enabled !== true) { throw migrationError( 'DESTINATION_VERIFICATION_FAILED', `${CURRENT_PLUGIN_ID} exists at ${destinationScope} scope but is not enabled.`, { phase: 'destination-verification', observedScopes, recovery: recoveryCommands(null, destinationScope), } ); } return { destination: installed[0], mode: 'already-migrated', observedScopes, sourceScope: null, }; } if (installed.length === 1) { return { destination: null, mode: 'migrate', observedScopes, sourceScope: installed[0].scope, }; } return { destination: installed.find(plugin => plugin.scope === destinationScope), mode: 'resume', observedScopes, sourceScope: installed.find(plugin => plugin.scope !== destinationScope).scope, }; } function validateExpectedScopes(plugins, expectedScopes, options = {}) { assertNoConflictingEccPlugins(plugins); const installed = currentEccPlugins(plugins); const observedScopes = installed.map(plugin => plugin.scope); const actual = [...observedScopes].sort(); const expected = [...expectedScopes].sort(); const destination = installed.find(plugin => plugin.scope === options.destinationScope); const matches = ( actual.length === expected.length && actual.every((scope, index) => scope === expected[index]) && destination?.enabled === true ); if (!matches) { throw migrationError( options.code, options.message, { phase: options.phase, observedScopes, recovery: options.recovery || [], } ); } return installed; } function plannedActions(migration, destinationScope, marketplaceAction, hookConfiguration) { const actions = []; if (migration.mode === 'migrate') { actions.push(marketplaceAction); actions.push([ 'plugin', 'install', CURRENT_PLUGIN_ID, '--scope', destinationScope, '--config', `hooks_enabled=${hookConfiguration.hooks_enabled}`, '--config', `hook_profile=${hookConfiguration.hook_profile}`, ]); } actions.push(['plugin', 'list', '--json']); actions.push(['plugin', 'list', '--json']); actions.push([ 'plugin', 'uninstall', CURRENT_PLUGIN_ID, '--scope', migration.sourceScope, '--keep-data', ]); actions.push(['plugin', 'list', '--json']); return actions; } function verifySourceAndDestination(run, paths, migration, destinationScope, phase) { const expectedScopes = [migration.sourceScope, destinationScope]; return validateExpectedScopes( readPluginInventory(run, paths.projectRoot, phase), expectedScopes, { code: phase === 'concurrency-check' ? 'CONCURRENT_SCOPE_CHANGE' : 'DESTINATION_VERIFICATION_FAILED', destinationScope, message: phase === 'concurrency-check' ? 'Claude plugin scopes changed during migration; the source was not removed.' : `Could not verify ${CURRENT_PLUGIN_ID} at the destination before source cleanup.`, phase, recovery: recoveryCommands(null, destinationScope), } ); } function uninstallSource(run, paths, migration, destinationScope) { const args = [ 'plugin', 'uninstall', CURRENT_PLUGIN_ID, '--scope', migration.sourceScope, '--keep-data', ]; try { run(args, { cwd: paths.projectRoot, phase: 'source-uninstall' }); return []; } catch { let observedScopes = [migration.sourceScope, destinationScope]; try { const plugins = readPluginInventory( run, paths.projectRoot, 'source-uninstall-verification' ); assertNoConflictingEccPlugins(plugins); const installed = currentEccPlugins(plugins); observedScopes = installed.map(plugin => plugin.scope); if ( installed.length === 1 && installed[0].scope === destinationScope && installed[0].enabled === true ) { return ['Claude reported an uninstall error, but destination-only state was verified.']; } } catch { // Preserve the safest known two-scope state in the structured recovery. } throw migrationError( 'SOURCE_UNINSTALL_FAILED', `The destination is installed, but Claude could not remove the ${migration.sourceScope} source scope.`, { phase: 'source-uninstall', observedScopes, recovery: recoveryCommands(migration.sourceScope, destinationScope), } ); } } function verifyFinalState(run, paths, destinationScope) { const plugins = readPluginInventory(run, paths.projectRoot, 'final-verification'); return validateExpectedScopes(plugins, [destinationScope], { code: 'FINAL_VERIFICATION_FAILED', destinationScope, message: `Could not verify destination-only ${CURRENT_PLUGIN_ID} state after source cleanup.`, phase: 'final-verification', recovery: recoveryCommands(null, destinationScope), }); } function migrateClaudePluginScope(options = {}, dependencies = {}) { if (!VALID_SCOPES.has(options.scope)) { throw migrationError( 'INVALID_SCOPE', 'Scope migration requires --scope user, project, or local.' ); } if (options.hooks !== undefined && !VALID_HOOK_MODES.has(options.hooks)) { throw migrationError('INVALID_HOOK_MODE', `Invalid hook mode: ${options.hooks}`); } const paths = resolveClaudePaths(options); const settingsPath = path.join(paths.configDir, 'settings.json'); const settings = readSettings(settingsPath); assertSafeLocalInventory(paths); const run = dependencies.runClaude || runClaude; const plugins = readPluginInventory(run, paths.projectRoot, 'inventory'); const migration = assertMigrationInventory(plugins, options.scope); const hooks = options.hooks === undefined ? deriveHookMode(settings) : options.hooks; const hookConfiguration = options.hooks === undefined ? readStoredHookOptions(settings) : hookOptions(options.hooks); const needsCommitAttributionPreference = needsClaudeCommitAttributionPreferenceWrite(settings); const marketplaces = parseMarketplaceList( run( ['plugin', 'marketplace', 'list', '--json'], { cwd: paths.projectRoot, phase: 'marketplace-inventory' } ).stdout ); const namedMarketplace = marketplaces.find(entry => entry?.name === 'ecc'); if (namedMarketplace && !isOfficialMarketplace(namedMarketplace)) { throw migrationError( 'MARKETPLACE_COLLISION', 'Refusing the `ecc` marketplace collision because it is not the official affaan-m/ECC source.', { phase: 'marketplace-inventory', observedScopes: migration.observedScopes, } ); } if (migration.mode === 'already-migrated') { const result = { action: 'already-migrated', hooks, pluginId: CURRENT_PLUGIN_ID, sourceScope: null, scope: options.scope, }; if (options.dryRun) { return { ...result, dryRun: true, preferencesUpdated: false, plannedActions: [ ...(options.hooks === undefined ? [] : [{ action: 'write-hook-preferences', ...hookConfiguration, }]), ...(needsCommitAttributionPreference ? [{ action: 'write-commit-attribution-preference', includeCoAuthoredBy: false, }] : []), ], }; } if (options.hooks !== undefined || needsCommitAttributionPreference) { writeClaudePluginOptions( settingsPath, options.hooks !== undefined ? options.hooks : undefined ); return { ...result, preferencesUpdated: true }; } return result; } let marketplaceAction = null; if (migration.mode === 'migrate') { marketplaceAction = namedMarketplace ? ['plugin', 'marketplace', 'update', 'ecc'] : [ 'plugin', 'marketplace', 'add', OFFICIAL_MARKETPLACE_URL, '--scope', options.scope, ]; } if (options.dryRun) { return { action: migration.mode === 'resume' ? 'would-resume' : 'would-migrate', dryRun: true, hooks, plannedActions: plannedActions( migration, options.scope, marketplaceAction, hookConfiguration ), pluginId: CURRENT_PLUGIN_ID, sourceScope: migration.sourceScope, scope: options.scope, }; } if (migration.mode === 'migrate') { ensureOfficialMarketplace({ marketplaces, projectRoot: paths.projectRoot, run, scope: options.scope, }); ensurePluginAtScope({ hookConfiguration, hooks, installed: false, projectRoot: paths.projectRoot, run, scope: options.scope, }); } verifySourceAndDestination( run, paths, migration, options.scope, 'destination-verification' ); verifySourceAndDestination( run, paths, migration, options.scope, 'concurrency-check' ); const warnings = uninstallSource(run, paths, migration, options.scope); verifyFinalState(run, paths, options.scope); if (options.hooks !== undefined || needsCommitAttributionPreference) { writeClaudePluginOptions( settingsPath, options.hooks !== undefined ? options.hooks : undefined ); } const result = { action: migration.mode === 'resume' ? 'resumed' : 'migrated', hooks, pluginId: CURRENT_PLUGIN_ID, sourceScope: migration.sourceScope, scope: options.scope, }; return warnings.length > 0 ? { ...result, warnings } : result; } module.exports = { migrateClaudePluginScope, };