'use strict'; const fs = require('fs'); const path = require('path'); const { writeInstallState } = require('../install-state'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); const { buildInstallIndex, isNamespacedSource, rewriteRelativeLinks } = require('./link-rewrite'); function isMarkdownPath(filePath) { return /\.(md|mdx|markdown)$/i.test(String(filePath || '')); } // Map every copy-file operation to { sourceRel, destRel } so relative links in // namespaced markdown can be rewritten to the file's actual installed location // (issue #2340). Returns null when the plan lacks the data needed to do so. function buildLinkIndexForPlan(plan) { if (!plan || !plan.targetRoot || !Array.isArray(plan.operations)) { return null; } const mappings = []; for (const operation of plan.operations) { if (operation.kind === 'copy-file' && operation.sourceRelativePath) { mappings.push({ sourceRel: operation.sourceRelativePath, destRel: path.relative(plan.targetRoot, operation.destinationPath), }); } } return buildInstallIndex(mappings); } function readJsonObject(filePath, label) { let parsed; try { parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch (error) { throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`); } if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`); } return parsed; } function cloneJsonValue(value) { if (value === undefined) { return undefined; } return JSON.parse(JSON.stringify(value)); } function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function deepMergeJson(baseValue, patchValue) { if (!isPlainObject(baseValue) || !isPlainObject(patchValue)) { return cloneJsonValue(patchValue); } const merged = { ...baseValue }; for (const [key, value] of Object.entries(patchValue)) { if (isPlainObject(value) && isPlainObject(merged[key])) { merged[key] = deepMergeJson(merged[key], value); } else { merged[key] = cloneJsonValue(value); } } return merged; } function formatJson(value) { return `${JSON.stringify(value, null, 2)}\n`; } function replacePluginRootPlaceholders(value, pluginRoot) { if (!pluginRoot) { return value; } if (typeof value === 'string') { return value.split('${CLAUDE_PLUGIN_ROOT}').join(pluginRoot); } if (Array.isArray(value)) { return value.map(item => replacePluginRootPlaceholders(item, pluginRoot)); } if (value && typeof value === 'object') { return Object.fromEntries( Object.entries(value).map(([key, nestedValue]) => [ key, replacePluginRootPlaceholders(nestedValue, pluginRoot), ]) ); } return value; } function findHooksSourcePath(plan, hooksDestinationPath) { const operation = plan.operations.find(item => item.destinationPath === hooksDestinationPath); return operation ? operation.sourcePath : null; } function isMcpConfigPath(filePath) { const basename = path.basename(String(filePath || '')); return basename === '.mcp.json' || basename === 'mcp.json'; } function buildResolvedClaudeHooks(plan) { if (!plan.adapter || (plan.adapter.target !== 'claude' && plan.adapter.target !== 'claude-project')) { return null; } const pluginRoot = plan.targetRoot; const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json'); const hooksSourcePath = findHooksSourcePath(plan, hooksDestinationPath) || hooksDestinationPath; if (!fs.existsSync(hooksSourcePath)) { return null; } const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config'); const resolvedHooks = replacePluginRootPlaceholders(hooksConfig.hooks, pluginRoot); if (!resolvedHooks || typeof resolvedHooks !== 'object' || Array.isArray(resolvedHooks)) { throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`); } return { hooksDestinationPath, resolvedHooksConfig: { ...hooksConfig, hooks: resolvedHooks, }, }; } function applyInstallPlan(plan) { const resolvedClaudeHooksPlan = buildResolvedClaudeHooks(plan); const disabledServers = parseDisabledMcpServers(process.env.ECC_DISABLED_MCPS); const linkIndex = buildLinkIndexForPlan(plan); for (const operation of plan.operations) { fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true }); if (operation.kind === 'merge-json') { const payload = cloneJsonValue(operation.mergePayload); if (payload === undefined) { throw new Error(`Missing merge payload for ${operation.destinationPath}`); } const filteredPayload = ( isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0 ) ? filterMcpConfig(payload, disabledServers).config : payload; const currentValue = fs.existsSync(operation.destinationPath) ? readJsonObject(operation.destinationPath, 'existing JSON config') : {}; const mergedValue = deepMergeJson(currentValue, filteredPayload); fs.writeFileSync(operation.destinationPath, formatJson(mergedValue), 'utf8'); continue; } if (operation.kind === 'copy-file' && isMcpConfigPath(operation.destinationPath) && disabledServers.length > 0) { const sourceConfig = readJsonObject(operation.sourcePath, 'MCP config'); const filteredConfig = filterMcpConfig(sourceConfig, disabledServers).config; fs.writeFileSync(operation.destinationPath, formatJson(filteredConfig), 'utf8'); continue; } // Namespaced markdown (e.g. skills/ -> skills/ecc/) needs its // relative cross-directory links rewritten so they resolve after install // (issue #2340). Files whose install path is unchanged (no namespace // injected) and all non-markdown files stay on the byte-for-byte copy path. if ( linkIndex && operation.kind === 'copy-file' && operation.sourceRelativePath && isMarkdownPath(operation.destinationPath) && isNamespacedSource(operation.sourceRelativePath, linkIndex) ) { const rewritten = rewriteRelativeLinks( fs.readFileSync(operation.sourcePath, 'utf8'), { sourceRel: operation.sourceRelativePath, index: linkIndex } ); fs.writeFileSync(operation.destinationPath, rewritten, 'utf8'); continue; } fs.copyFileSync(operation.sourcePath, operation.destinationPath); } if (resolvedClaudeHooksPlan) { fs.mkdirSync(path.dirname(resolvedClaudeHooksPlan.hooksDestinationPath), { recursive: true }); fs.writeFileSync( resolvedClaudeHooksPlan.hooksDestinationPath, JSON.stringify(resolvedClaudeHooksPlan.resolvedHooksConfig, null, 2) + '\n', 'utf8' ); } writeInstallState(plan.installStatePath, plan.statePreview); return { ...plan, applied: true, }; } module.exports = { applyInstallPlan, };