Files
ECC/scripts/lib/install-executor.js
T
6aaa41e028 feat(install): require an explicit hook decision at the apply layer
The guided installer asks how ECC hooks should run, but that consent
lived only in the wizard path. Running install-apply directly with a
profile that includes hooks-runtime still materialized the hook runtime
with no disclosure and no decision.

Gate the apply layer instead, so every entry point is covered:

- disclose the six hook capability groups when a plan would materialize
  the hook runtime, and refuse to apply until the caller decides
- --enable-hooks confirms the hook runtime; --no-hooks installs the rest
  of the selection without it and records the reduced module closure in
  install-state
- surface the pending decision as a dry-run warning
- show the same capability disclosure in the guided installer's plan
  preview, so the wizard's hook question states what it is asking about

Plans that never materialize hooks (Kimi, --profile minimal,
--without baseline:hooks) are unaffected and need no flag. Repair and
uninstall operate on already-recorded state and stay unchanged.

The capability taxonomy and the held-materialization behavior come from
Samarjeet Singh Tomar's PR #2634, reworked to fit the single-decision
consent model that shipped with the guided installer in #2649.

Co-Authored-By: Samarjeet Singh Tomar <samar_tomar@hotmail.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 15:09:34 -04:00

572 lines
18 KiB
JavaScript

const fs = require('fs');
const os = require('os');
const path = require('path');
const { toCursorAgentRelativePath } = require('./cursor-agent-names');
const { LEGACY_INSTALL_TARGETS, parseInstallArgs } = require('./install/request');
const {
buildCopyFileOperation,
createManifestInstallPlan,
createStatePreview,
dedupeCopyFileOperations,
getManifestVersion,
getPackageVersion,
getRepoCommit,
getSourceRoot,
listFilesRecursive,
readJsonObject,
} = require('./install/plan');
const { SUPPORTED_INSTALL_TARGETS, listLegacyCompatibilityLanguages, resolveLegacyCompatibilitySelection } = require('./install-manifests');
const { getInstallTargetAdapter } = require('./install-targets/registry');
const { resolveInvocationEnvironment } = require('./invocation-environment');
const LANGUAGE_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
const CLAUDE_ECC_NAMESPACE = 'ecc';
function readDirectoryNames(dirPath) {
if (!fs.existsSync(dirPath)) {
return [];
}
return fs
.readdirSync(dirPath, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
}
function listAvailableLanguages(sourceRoot = getSourceRoot()) {
return [...new Set([...listLegacyCompatibilityLanguages(), ...readDirectoryNames(path.join(sourceRoot, 'rules')).filter(name => name !== 'common')])].sort();
}
function validateLegacyTarget(target) {
if (LEGACY_INSTALL_TARGETS.includes(target)) {
return;
}
// A target can be fully supported yet not installable via the bare-language
// positional syntax (which is legacy-only). Guide the user to the right mode
// instead of implying the target is unknown (#2282).
if (SUPPORTED_INSTALL_TARGETS.includes(target)) {
throw new Error(
`Target '${target}' is supported, but the bare-language install syntax only accepts ${LEGACY_INSTALL_TARGETS.join(', ')}. ` +
`Install '${target}' with a component selection instead, e.g. \`install.sh --target ${target} --profile full\` ` +
`(or --modules <id,...> / --skills <id,...>).`
);
}
throw new Error(`Unknown install target: ${target}. Expected one of ${SUPPORTED_INSTALL_TARGETS.join(', ')}`);
}
function applyInstallPlan(plan, dependencies = {}) {
const { applyInstallPlan: applyPlan } = require('./install/apply');
return applyPlan(plan, dependencies);
}
function previewInstallPlan(plan) {
const { previewInstallPlan: previewPlan } = require('./install/apply');
return previewPlan(plan);
}
function addRecursiveCopyOperations(operations, options) {
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
if (!fs.existsSync(sourceDir)) {
return 0;
}
const relativeFiles = listFilesRecursive(sourceDir);
for (const relativeFile of relativeFiles) {
const sourceRelativePath = path.join(options.sourceRelativeDir, relativeFile);
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
const destinationRelativePath = typeof options.destinationRelativePathTransform === 'function' ? options.destinationRelativePathTransform(relativeFile, sourceRelativePath) : relativeFile;
if (!destinationRelativePath) {
continue;
}
const destinationPath = path.join(options.destinationDir, destinationRelativePath);
operations.push(
buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'preserve-relative-path',
contentTransform: options.contentTransform,
})
);
}
return relativeFiles.length;
}
function addFileCopyOperation(operations, options) {
const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath);
if (!fs.existsSync(sourcePath)) {
return false;
}
operations.push(
buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath: options.sourceRelativePath,
destinationPath: options.destinationPath,
strategy: options.strategy || 'preserve-relative-path'
})
);
return true;
}
function addCursorAgentDataScaffoldOperations(operations, options) {
const scaffoldRoot = path.join(options.sourceRoot, 'scaffolds', 'cursor');
if (!fs.existsSync(scaffoldRoot)) {
return;
}
addFileCopyOperation(operations, {
moduleId: options.moduleId,
sourceRoot: options.sourceRoot,
sourceRelativePath: path.join('scaffolds', 'cursor', 'ecc-agent-data.json'),
destinationPath: path.join(options.targetRoot, 'ecc-agent-data.json'),
strategy: 'preserve-relative-path'
});
addFileCopyOperation(operations, {
moduleId: options.moduleId,
sourceRoot: options.sourceRoot,
sourceRelativePath: path.join('scaffolds', 'cursor', 'rules', 'ecc-agent-data-home.mdc'),
destinationPath: path.join(options.targetRoot, 'rules', 'ecc-agent-data-home.mdc'),
strategy: 'preserve-relative-path'
});
addJsonMergeOperation(operations, {
moduleId: options.moduleId,
sourceRoot: options.sourceRoot,
sourceRelativePath: path.join('scaffolds', 'cursor', 'hooks.json'),
destinationPath: path.join(options.targetRoot, 'hooks.json')
});
const cursorSessionHookDeps = [path.join('scripts', 'hooks', 'cursor-session-env.js'), path.join('scripts', 'lib', 'agent-data-home.js'), path.join('scripts', 'lib', 'utils.js')];
for (const sourceRelativePath of cursorSessionHookDeps) {
addFileCopyOperation(operations, {
moduleId: options.moduleId,
sourceRoot: options.sourceRoot,
sourceRelativePath,
destinationPath: path.join(options.targetRoot, sourceRelativePath),
strategy: 'preserve-relative-path'
});
}
}
function addJsonMergeOperation(operations, options) {
const sourcePath = path.join(options.sourceRoot, options.sourceRelativePath);
if (!fs.existsSync(sourcePath)) {
return false;
}
operations.push({
kind: 'merge-json',
moduleId: options.moduleId,
sourceRelativePath: options.sourceRelativePath,
destinationPath: options.destinationPath,
strategy: 'merge-json',
ownership: 'managed',
scaffoldOnly: false,
mergePayload: readJsonObject(sourcePath, options.sourceRelativePath)
});
return true;
}
function addMatchingRuleOperations(operations, options) {
const sourceDir = path.join(options.sourceRoot, options.sourceRelativeDir);
if (!fs.existsSync(sourceDir)) {
return 0;
}
const files = fs
.readdirSync(sourceDir, { withFileTypes: true })
.filter(entry => entry.isFile() && options.matcher(entry.name))
.map(entry => entry.name)
.sort();
for (const fileName of files) {
const sourceRelativePath = path.join(options.sourceRelativeDir, fileName);
const sourcePath = path.join(options.sourceRoot, sourceRelativePath);
const destinationPath = path.join(options.destinationDir, options.rename ? options.rename(fileName) : fileName);
operations.push(
buildCopyFileOperation({
moduleId: options.moduleId,
sourcePath,
sourceRelativePath,
destinationPath,
strategy: options.strategy || 'flatten-copy'
})
);
}
return files.length;
}
function isDirectoryNonEmpty(dirPath) {
return fs.existsSync(dirPath) && fs.statSync(dirPath).isDirectory() && fs.readdirSync(dirPath).length > 0;
}
function planClaudeStyleLegacyInstall(context, { adapterId, adapterRootInput, rulesDir: rulesDirOverride }) {
const adapter = getInstallTargetAdapter(adapterId);
const targetRoot = adapter.resolveRoot(adapterRootInput);
const rulesDir = rulesDirOverride || path.join(targetRoot, 'rules', CLAUDE_ECC_NAMESPACE);
const installStatePath = adapter.getInstallStatePath(adapterRootInput);
const operations = [];
const warnings = [];
if (isDirectoryNonEmpty(rulesDir)) {
warnings.push(`Destination ${rulesDir}/ already exists and files may be overwritten`);
}
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-claude-rules',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', 'common'),
destinationDir: path.join(rulesDir, 'common')
});
for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
continue;
}
const sourceDir = path.join(context.sourceRoot, 'rules', language);
if (!fs.existsSync(sourceDir)) {
warnings.push(`rules/${language}/ does not exist, skipping`);
continue;
}
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-claude-rules',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', language),
destinationDir: path.join(rulesDir, language)
});
}
return {
mode: 'legacy',
sourceRoot: context.sourceRoot,
adapter,
target: adapterId,
targetRoot,
installRoot: rulesDir,
installStatePath,
operations,
warnings,
selectedModules: ['legacy-claude-rules']
};
}
function planClaudeLegacyInstall(context) {
return planClaudeStyleLegacyInstall(context, {
adapterId: 'claude',
adapterRootInput: { homeDir: context.homeDir },
rulesDir: context.claudeRulesDir || null
});
}
function planClaudeProjectLegacyInstall(context) {
return planClaudeStyleLegacyInstall(context, {
adapterId: 'claude-project',
adapterRootInput: { repoRoot: context.projectRoot },
rulesDir: null
});
}
function planCursorLegacyInstall(context) {
const adapter = getInstallTargetAdapter('cursor');
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
const operations = [];
const warnings = [];
addMatchingRuleOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'rules'),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => /^common-.*\.md$/.test(fileName)
});
for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
continue;
}
const matches = addMatchingRuleOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'rules'),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.startsWith(`${language}-`) && fileName.endsWith('.md')
});
if (matches === 0) {
warnings.push(`No Cursor rules for '${language}' found, skipping`);
}
}
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'agents'),
destinationDir: path.join(targetRoot, 'agents'),
destinationRelativePathTransform: toCursorAgentRelativePath
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'skills'),
destinationDir: path.join(targetRoot, 'skills')
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'commands'),
destinationDir: path.join(targetRoot, 'commands')
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('.cursor', 'hooks'),
destinationDir: path.join(targetRoot, 'hooks')
});
addFileCopyOperation(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativePath: path.join('.cursor', 'hooks.json'),
destinationPath: path.join(targetRoot, 'hooks.json')
});
addJsonMergeOperation(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
sourceRelativePath: '.mcp.json',
destinationPath: path.join(targetRoot, 'mcp.json')
});
addCursorAgentDataScaffoldOperations(operations, {
moduleId: 'legacy-cursor-install',
sourceRoot: context.sourceRoot,
targetRoot
});
return {
mode: 'legacy',
adapter,
target: 'cursor',
targetRoot,
installRoot: targetRoot,
installStatePath,
operations,
warnings,
selectedModules: ['legacy-cursor-install']
};
}
function planAntigravityLegacyInstall(context) {
const adapter = getInstallTargetAdapter('antigravity');
const targetRoot = adapter.resolveRoot({ repoRoot: context.projectRoot });
const installStatePath = adapter.getInstallStatePath({ repoRoot: context.projectRoot });
const operations = [];
const warnings = [];
if (isDirectoryNonEmpty(path.join(targetRoot, 'rules'))) {
warnings.push(`Destination ${path.join(targetRoot, 'rules')}/ already exists and files may be overwritten`);
}
addMatchingRuleOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', 'common'),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.endsWith('.md'),
rename: fileName => `common-${fileName}`
});
for (const language of context.languages) {
if (!LANGUAGE_NAME_PATTERN.test(language)) {
warnings.push(`Invalid language name '${language}'. Only alphanumeric, dash, and underscore are allowed`);
continue;
}
const sourceDir = path.join(context.sourceRoot, 'rules', language);
if (!fs.existsSync(sourceDir)) {
warnings.push(`rules/${language}/ does not exist, skipping`);
continue;
}
addMatchingRuleOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: path.join('rules', language),
destinationDir: path.join(targetRoot, 'rules'),
matcher: fileName => fileName.endsWith('.md'),
rename: fileName => `${language}-${fileName}`
});
}
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'commands',
destinationDir: path.join(targetRoot, 'workflows')
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'agents',
destinationDir: path.join(targetRoot, 'agents'),
contentTransform: 'antigravity-agent-frontmatter'
});
addRecursiveCopyOperations(operations, {
moduleId: 'legacy-antigravity-install',
sourceRoot: context.sourceRoot,
sourceRelativeDir: 'skills',
destinationDir: path.join(targetRoot, 'skills')
});
return {
mode: 'legacy',
adapter,
target: 'antigravity',
targetRoot,
installRoot: targetRoot,
installStatePath,
operations,
warnings,
selectedModules: ['legacy-antigravity-install']
};
}
function createLegacyInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd();
const homeDir = options.homeDir || process.env.HOME || os.homedir();
const target = options.target || 'claude';
validateLegacyTarget(target);
const context = {
sourceRoot,
projectRoot,
homeDir,
languages: Array.isArray(options.languages) ? options.languages : [],
claudeRulesDir: options.claudeRulesDir || process.env.CLAUDE_RULES_DIR || null
};
let plan;
if (target === 'claude') {
plan = planClaudeLegacyInstall(context);
} else if (target === 'claude-project') {
plan = planClaudeProjectLegacyInstall(context);
} else if (target === 'cursor') {
plan = planCursorLegacyInstall(context);
} else {
plan = planAntigravityLegacyInstall(context);
}
const source = {
repoVersion: getPackageVersion(sourceRoot),
repoCommit: getRepoCommit(sourceRoot),
manifestVersion: getManifestVersion(sourceRoot)
};
const statePreview = createStatePreview({
adapter: plan.adapter,
targetRoot: plan.targetRoot,
installStatePath: plan.installStatePath,
request: {
profile: null,
modules: [],
legacyLanguages: context.languages,
legacyMode: true
},
resolution: {
selectedModules: plan.selectedModules,
skippedModules: []
},
operations: plan.operations,
source
});
return {
mode: 'legacy',
sourceRoot,
target: plan.target,
adapter: {
id: plan.adapter.id,
target: plan.adapter.target,
kind: plan.adapter.kind
},
targetRoot: plan.targetRoot,
installRoot: plan.installRoot,
installStatePath: plan.installStatePath,
warnings: plan.warnings,
languages: context.languages,
operations: plan.operations,
statePreview
};
}
function createLegacyCompatInstallPlan(options = {}) {
const sourceRoot = options.sourceRoot || getSourceRoot();
const projectRoot = options.projectRoot || process.cwd();
const target = options.target || 'claude';
const includeComponentIds = Array.isArray(options.includeComponentIds) ? [...options.includeComponentIds] : [];
const excludeComponentIds = Array.isArray(options.excludeComponentIds) ? [...options.excludeComponentIds] : [];
validateLegacyTarget(target);
const selection = resolveLegacyCompatibilitySelection({
repoRoot: sourceRoot,
target,
legacyLanguages: options.legacyLanguages || []
});
return createManifestInstallPlan({
sourceRoot,
projectRoot,
homeDir: options.homeDir,
env: resolveInvocationEnvironment(options),
target,
profileId: null,
moduleIds: selection.moduleIds,
includeComponentIds,
excludeComponentIds,
legacyLanguages: selection.legacyLanguages,
ruleLanguages: selection.ruleLanguages,
legacyMode: true,
exemptValidationCodes: options.exemptValidationCodes || [],
requestProfileId: null,
requestModuleIds: [],
requestIncludeComponentIds: includeComponentIds,
requestExcludeComponentIds: excludeComponentIds,
mode: 'legacy-compat'
});
}
module.exports = {
SUPPORTED_INSTALL_TARGETS,
LEGACY_INSTALL_TARGETS,
applyInstallPlan,
previewInstallPlan,
createLegacyCompatInstallPlan,
createManifestInstallPlan,
createLegacyInstallPlan,
dedupeCopyFileOperations,
getSourceRoot,
listAvailableLanguages,
parseInstallArgs
};