From 6aaa41e02847b153086fb72c68f0652b569280ec Mon Sep 17 00:00:00 2001 From: haelyra <49814733+haelyra@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:18:14 -0400 Subject: [PATCH] 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 Co-Authored-By: Claude Fable 5 --- README.md | 12 +- hooks/README.md | 4 +- schemas/install-state.schema.json | 7 + scripts/auto-update.js | 8 + scripts/install-apply.js | 3 + scripts/install-guided.js | 8 + scripts/lib/install-executor.js | 1 + scripts/lib/install-lifecycle.js | 56 ++--- scripts/lib/install-state.js | 13 +- scripts/lib/install/apply.js | 6 + scripts/lib/install/hook-consent.js | 203 ++++++++++++++++++ scripts/lib/install/request.js | 12 ++ scripts/lib/install/runtime.js | 7 + tests/lib/hook-consent.test.js | 149 +++++++++++++ tests/lib/install-executor.test.js | 1 + tests/lib/install-lifecycle.test.js | 76 +++++++ tests/lib/install-request.test.js | 39 +++- tests/lib/install-state.test.js | 2 + tests/lib/selective-install.test.js | 4 + tests/scripts/auto-update.test.js | 37 ++++ tests/scripts/install-apply.test.js | 93 ++++++-- tests/scripts/install-guided.test.js | 23 ++ tests/scripts/install-readme-clarity.test.js | 4 + .../scripts/manual-hook-install-docs.test.js | 8 +- tests/scripts/repair.test.js | 48 ++++- tests/scripts/uninstall.test.js | 2 +- 26 files changed, 768 insertions(+), 58 deletions(-) create mode 100644 scripts/lib/install/hook-consent.js create mode 100644 tests/lib/hook-consent.test.js diff --git a/README.md b/README.md index 2d7f040b9..63453f323 100644 --- a/README.md +++ b/README.md @@ -413,13 +413,19 @@ For the normal core profile with hooks disabled: ```bash ./install.sh --profile core --without baseline:hooks --target claude +./install.sh --profile core --no-hooks --target claude ``` Add the hook runtime later only if you want it: ```bash -./install.sh --target claude --modules hooks-runtime +./install.sh --target claude --modules hooks-runtime --enable-hooks ``` + +Any install whose profile or modules would materialize the hook runtime requires +an explicit decision. Without `--enable-hooks` or `--no-hooks`, the installer +prints what the hooks can do and stops before writing anything. The guided +installer (`ecc install --guided`) asks for this choice interactively.
@@ -510,7 +516,7 @@ For hand-picked manual installs, Claude discovers skills as direct children of ` Do not copy the raw repo `hooks/hooks.json` into `~/.claude/settings.json` or `~/.claude/hooks/hooks.json`. That file is plugin/repo-oriented; use the installer so hook command paths are rewritten correctly: ```bash -bash ./install.sh --target claude --modules hooks-runtime +bash ./install.sh --target claude --modules hooks-runtime --enable-hooks ``` That writes resolved hooks to `~/.claude/hooks/hooks.json` and leaves any existing `~/.claude/settings.json` untouched. @@ -520,7 +526,7 @@ If you installed ECC via `/plugin install`, do not copy those hooks into `settin On Windows, Claude's config root is `%USERPROFILE%\\.claude`; install the hook runtime with: ```powershell -pwsh -File .\install.ps1 --target claude --modules hooks-runtime +pwsh -File .\install.ps1 --target claude --modules hooks-runtime --enable-hooks ``` #### Configure MCPs diff --git a/hooks/README.md b/hooks/README.md index 09ff7921e..510dfa755 100644 --- a/hooks/README.md +++ b/hooks/README.md @@ -26,11 +26,11 @@ For Claude Code manual installs, do not paste the raw repo `hooks.json` into `~/ Use the installer instead so hook commands are rewritten against your actual Claude root: ```bash -bash ./install.sh --target claude --modules hooks-runtime +bash ./install.sh --target claude --modules hooks-runtime --enable-hooks ``` ```powershell -pwsh -File .\install.ps1 --target claude --modules hooks-runtime +pwsh -File .\install.ps1 --target claude --modules hooks-runtime --enable-hooks ``` That installs resolved hooks to `~/.claude/hooks/hooks.json`. On Windows, the Claude config root is `%USERPROFILE%\\.claude`. diff --git a/schemas/install-state.schema.json b/schemas/install-state.schema.json index 0b2281211..976d5129b 100644 --- a/schemas/install-state.schema.json +++ b/schemas/install-state.schema.json @@ -107,6 +107,13 @@ }, "legacyMode": { "type": "boolean" + }, + "hookConsent": { + "enum": [ + "enabled", + "declined", + null + ] } } }, diff --git a/scripts/auto-update.js b/scripts/auto-update.js index 52c83c06f..3612dab88 100644 --- a/scripts/auto-update.js +++ b/scripts/auto-update.js @@ -6,6 +6,7 @@ const path = require('path'); const { spawnSync } = require('child_process'); const { discoverInstalledStates } = require('./lib/install-lifecycle'); +const { getRecordedHookConsent } = require('./lib/install/hook-consent'); const { SUPPORTED_INSTALL_TARGETS } = require('./lib/install-manifests'); function showHelp(exitCode = 0) { @@ -85,6 +86,7 @@ function buildInstallApplyArgs(record) { const target = state.target.target || record.adapter.target; const request = state.request || {}; const args = []; + const hookConsent = getRecordedHookConsent(state); if (target) { args.push('--target', target); @@ -106,6 +108,12 @@ function buildInstallApplyArgs(record) { args.push('--without', componentId); } + if (hookConsent === 'enabled') { + args.push('--enable-hooks'); + } else if (hookConsent === 'declined') { + args.push('--no-hooks'); + } + for (const language of Array.isArray(request.legacyLanguages) ? request.legacyLanguages : []) { args.push(language); } diff --git a/scripts/install-apply.js b/scripts/install-apply.js index 128085c6c..40b8c7993 100755 --- a/scripts/install-apply.js +++ b/scripts/install-apply.js @@ -59,6 +59,9 @@ Options: --locale Install translated docs to ~/.claude/docs// (or ./.claude/docs// for claude-project) (claude or claude-project target only; can be combined with --profile or --with) --config Load install intent from ecc-install.json + --enable-hooks Confirm installing the automatic hook runtime (required + when the selected profile/modules materialize hooks) + --no-hooks Install everything except the automatic hook runtime --dry-run Show the install plan without copying files --json Emit machine-readable plan/result JSON --help Show this help text diff --git a/scripts/install-guided.js b/scripts/install-guided.js index 31ede016c..4fa27525d 100644 --- a/scripts/install-guided.js +++ b/scripts/install-guided.js @@ -16,6 +16,7 @@ const { createMultiHarnessPlan, normalizeGuidedInstallRequest, } = require('./lib/multi-harness-setup'); +const { formatHookCapabilityDisclosure } = require('./lib/install/hook-consent'); const { startTerminalSpinner } = require('./lib/terminal-spinner'); const { showTerminalWelcome } = require('./lib/terminal-welcome'); const { stripAnsi } = require('./lib/utils'); @@ -209,6 +210,13 @@ function printPlan(plan, output) { if (plan.request.harnesses.includes('kimi')) { output.write('\nKimi note: ECC hooks are not configured; model, provider, and authentication settings are unchanged.\n'); } + if (plan.request.harnesses.includes('claude') && plan.request.claudeHooks && plan.request.claudeHooks !== 'off') { + output.write( + `\nClaude hook profile '${plan.request.claudeHooks}' enables automation that can:\n` + + `${formatHookCapabilityDisclosure()}\n` + + "Choose '--claude-hooks off' to install without automatic hook behavior.\n" + ); + } } async function confirmPlan(terminal, output) { diff --git a/scripts/lib/install-executor.js b/scripts/lib/install-executor.js index a903825c4..72eac5e8a 100644 --- a/scripts/lib/install-executor.js +++ b/scripts/lib/install-executor.js @@ -547,6 +547,7 @@ function createLegacyCompatInstallPlan(options = {}) { legacyLanguages: selection.legacyLanguages, ruleLanguages: selection.ruleLanguages, legacyMode: true, + exemptValidationCodes: options.exemptValidationCodes || [], requestProfileId: null, requestModuleIds: [], requestIncludeComponentIds: includeComponentIds, diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index bc2ef7bd8..c10b1cfe3 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -4,10 +4,11 @@ const { execFileSync } = require('child_process'); const os = require('os'); const path = require('path'); -const { resolveInstallPlan, loadInstallManifests } = require('./install-manifests'); +const { loadInstallManifests } = require('./install-manifests'); const { readInstallState, validateInstallState } = require('./install-state'); const { assertWithinTrustedRoot } = require('./path-safety'); -const { createManifestInstallPlan } = require('./install-executor'); +const { createInstallPlanFromRequest } = require('./install/runtime'); +const { getRecordedHookConsent } = require('./install/hook-consent'); const { prepareClaudeSkillMigration, } = require('./install/claude-skill-migration'); @@ -65,6 +66,32 @@ function compareStringArrays(left, right) { return leftValues.every((value, index) => value === rightValues[index]); } +function buildRecordedManifestRequest(record) { + const state = record.state || {}; + const request = state.request || {}; + + return { + mode: 'manifest', + target: state.target && state.target.target ? state.target.target : record.adapter.target, + profileId: request.profile || null, + moduleIds: Array.isArray(request.modules) ? [...request.modules] : [], + includeComponentIds: Array.isArray(request.includeComponents) ? [...request.includeComponents] : [], + excludeComponentIds: Array.isArray(request.excludeComponents) ? [...request.excludeComponents] : [], + legacyLanguages: Array.isArray(request.legacyLanguages) ? [...request.legacyLanguages] : [], + hookConsent: getRecordedHookConsent(state), + }; +} + +function resolveRecordedManifestPlan(record, context, options = {}) { + return createInstallPlanFromRequest(buildRecordedManifestRequest(record), { + sourceRoot: context.repoRoot, + projectRoot: context.projectRoot, + homeDir: context.homeDir, + env: context.env, + exemptValidationCodes: options.exemptValidationCodes || [], + }); +} + function hasOpencodeBuildError(issues) { return Array.isArray(issues) && issues.some(issue => issue.code === OPENCODE_PLUGIN_NOT_BUILT_CODE); } @@ -1506,17 +1533,7 @@ function analyzeRecord(record, context) { if (!state.request.legacyMode) { try { - const desiredPlan = resolveInstallPlan({ - repoRoot: context.repoRoot, - projectRoot: context.projectRoot, - homeDir: context.homeDir, - env: context.env, - target: record.adapter.target, - profileId: state.request.profile || null, - moduleIds: state.request.modules || [], - includeComponentIds: state.request.includeComponents || [], - excludeComponentIds: state.request.excludeComponents || [] - }); + const desiredPlan = resolveRecordedManifestPlan(record, context); if (!compareStringArrays(desiredPlan.selectedModuleIds, state.resolution.selectedModules) || !compareStringArrays(desiredPlan.skippedModuleIds, state.resolution.skippedModules)) { issues.push( @@ -1614,18 +1631,7 @@ function createRepairPlanFromRecord(record, context, options = {}) { }; } - const desiredPlan = createManifestInstallPlan({ - sourceRoot: context.repoRoot, - target: record.adapter.target, - profileId: state.request.profile || null, - moduleIds: state.request.modules || [], - includeComponentIds: state.request.includeComponents || [], - excludeComponentIds: state.request.excludeComponents || [], - projectRoot: context.projectRoot, - homeDir: context.homeDir, - env: context.env, - exemptValidationCodes: options.exemptValidationCodes || [], - }); + const desiredPlan = resolveRecordedManifestPlan(record, context, options); return { ...desiredPlan, diff --git a/scripts/lib/install-state.js b/scripts/lib/install-state.js index 5776752cf..a0aa3bbe6 100644 --- a/scripts/lib/install-state.js +++ b/scripts/lib/install-state.js @@ -127,7 +127,7 @@ function createFallbackValidator() { validateNoAdditionalProperties( request, '/request', - ['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode'] + ['profile', 'modules', 'includeComponents', 'excludeComponents', 'legacyLanguages', 'legacyMode', 'hookConsent'] ); if (!(Object.prototype.hasOwnProperty.call(request, 'profile') && (request.profile === null || typeof request.profile === 'string'))) { pushError('/request/profile', 'must be string or null'); @@ -139,6 +139,14 @@ function createFallbackValidator() { if (typeof request.legacyMode !== 'boolean') { pushError('/request/legacyMode', 'must be boolean'); } + if ( + request.hookConsent !== undefined + && request.hookConsent !== null + && request.hookConsent !== 'enabled' + && request.hookConsent !== 'declined' + ) { + pushError('/request/hookConsent', 'must be enabled, declined, or null'); + } } const resolution = state.resolution; @@ -258,6 +266,9 @@ function createInstallState(options) { ? [...options.request.legacyLanguages] : [], legacyMode: Boolean(options.request.legacyMode), + hookConsent: Object.prototype.hasOwnProperty.call(options.request, 'hookConsent') + ? options.request.hookConsent + : null, }, resolution: { selectedModules: Array.isArray(options.resolution.selectedModules) diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 2ca0e45cc..e755586a8 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -9,6 +9,7 @@ const { withCommitAttributionDisabled, } = require('../claude-commit-attribution'); const { writeInstallState } = require('../install-state'); +const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); const { assertWithinTrustedRoot } = require('../path-safety'); const { @@ -335,6 +336,9 @@ function buildResolvedClaudeHooks(plan) { function previewInstallPlan(plan) { const migration = prepareClaudeSkillMigration(plan); + const hookConsentWarnings = planMaterializesHookRuntime(plan) && plan.hookConsent !== 'enabled' + ? ['Applying this plan requires an explicit hook decision: --enable-hooks or --no-hooks.'] + : []; return { ...plan, statePreview: migration.finalState, @@ -344,12 +348,14 @@ function previewInstallPlan(plan) { warnings: [ ...(Array.isArray(plan.warnings) ? plan.warnings : []), ...migration.warnings, + ...hookConsentWarnings, ], applied: false, }; } function applyInstallPlan(plan, dependencies = {}) { + assertHookConsentReady(plan); const persistInstallState = dependencies.writeInstallState || writeInstallState; const beforeInstallStateRead = dependencies.beforeInstallStateRead; const beforeOperationWrite = dependencies.beforeOperationWrite; diff --git a/scripts/lib/install/hook-consent.js b/scripts/lib/install/hook-consent.js new file mode 100644 index 000000000..a97fc5187 --- /dev/null +++ b/scripts/lib/install/hook-consent.js @@ -0,0 +1,203 @@ +'use strict'; + +/** + * Explicit consent gate for materializing the automatic hook runtime. + * + * The capability disclosure and held-materialization semantics were + * contributed in PR #2634 by Samarjeet Singh Tomar (@samartomar); this + * module integrates them with the single-decision consent model used by + * the guided installer. + */ + +const HOOK_CAPABILITY_GROUPS = Object.freeze([ + Object.freeze({ + id: 'automatic-source-writes', + description: 'Automatically format or otherwise modify project source files.', + }), + Object.freeze({ + id: 'command-rewrite-and-process-control', + description: 'Rewrite requested commands and start, replace, or terminate processes.', + }), + Object.freeze({ + id: 'transcript-derived-llm-egress', + description: 'Send transcript-derived conversation text to an external LLM.', + }), + Object.freeze({ + id: 'mcp-network-and-process-activity', + description: 'Probe MCP endpoints and launch, reconnect, or terminate MCP processes.', + }), + Object.freeze({ + id: 'automatic-permission-gates', + description: 'Automatically deny or alter Edit, Write, Bash, and configuration operations.', + }), + Object.freeze({ + id: 'session-observation-and-cost-records', + description: 'Persist session, observation, governance, notification, and cost records.', + }), +]); + +const HOOK_CONSENT_DECISIONS = Object.freeze(['enabled', 'declined']); +const HOOK_RUNTIME_MODULE_ID = 'hooks-runtime'; + +function normalizeOperationPath(value) { + return String(value || '').replace(/\\/g, '/').toLowerCase(); +} + +function isHookRuntimeOperation(operation = {}) { + if (operation.moduleId === HOOK_RUNTIME_MODULE_ID) { + return true; + } + + const source = normalizeOperationPath(operation.sourceRelativePath); + const destination = normalizeOperationPath(operation.destinationPath); + return ( + source === 'hooks' + || source.startsWith('hooks/') + || source === '.cursor/hooks' + || source.startsWith('.cursor/hooks/') + || source === '.cursor/hooks.json' + || source === '.opencode/plugins' + || source.startsWith('.opencode/plugins/') + || source === '.opencode/dist/plugins' + || source.startsWith('.opencode/dist/plugins/') + || destination.endsWith('/hooks/hooks.json') + || destination.endsWith('/.cursor/hooks.json') + || destination.includes('/.cursor/hooks/') + ); +} + +function planMaterializesHookRuntime(plan = {}) { + const operations = Array.isArray(plan.operations) ? plan.operations : []; + return operations.some(isHookRuntimeOperation); +} + +function formatHookCapabilityDisclosure(indent = ' ') { + return HOOK_CAPABILITY_GROUPS + .map((group, index) => `${indent}${index + 1}. ${group.description}`) + .join('\n'); +} + +function resolveHookConsentFlags({ enableHooks = false, noHooks = false } = {}) { + if (enableHooks && noHooks) { + throw new Error('--enable-hooks and --no-hooks are mutually exclusive'); + } + if (enableHooks) { + return 'enabled'; + } + if (noHooks) { + return 'declined'; + } + return null; +} + +function withoutHookRuntimeId(values) { + return (Array.isArray(values) ? values : []).filter(value => value !== HOOK_RUNTIME_MODULE_ID); +} + +function setStatePreviewHookConsent(statePreview, hookConsent) { + if (!statePreview || !statePreview.request) { + return statePreview; + } + + return { + ...statePreview, + request: { + ...statePreview.request, + hookConsent, + }, + }; +} + +function getRecordedHookConsent(state = {}) { + const explicitDecision = state.request && HOOK_CONSENT_DECISIONS.includes(state.request.hookConsent) + ? state.request.hookConsent + : null; + if (explicitDecision) { + return explicitDecision; + } + + if (Array.isArray(state.request && state.request.modules) && state.request.modules.includes(HOOK_RUNTIME_MODULE_ID)) { + return 'enabled'; + } + + if (Array.isArray(state.resolution && state.resolution.selectedModules) && state.resolution.selectedModules.includes(HOOK_RUNTIME_MODULE_ID)) { + return 'enabled'; + } + + if (planMaterializesHookRuntime(state)) { + return 'enabled'; + } + + return null; +} + +function stripHookRuntimeFromPlan(plan) { + const hadHookRuntimeModule = Array.isArray(plan.selectedModuleIds) + && plan.selectedModuleIds.includes('hooks-runtime'); + const operations = (Array.isArray(plan.operations) ? plan.operations : []) + .filter(operation => !isHookRuntimeOperation(operation)); + const statePreview = plan.statePreview + ? { + ...plan.statePreview, + operations: (Array.isArray(plan.statePreview.operations) ? plan.statePreview.operations : []) + .filter(operation => !isHookRuntimeOperation(operation)), + resolution: plan.statePreview.resolution + ? { + ...plan.statePreview.resolution, + selectedModules: withoutHookRuntimeId(plan.statePreview.resolution.selectedModules), + } + : plan.statePreview.resolution, + } + : plan.statePreview; + + return { + ...plan, + operations, + statePreview: setStatePreviewHookConsent(statePreview, 'declined'), + selectedModuleIds: withoutHookRuntimeId(plan.selectedModuleIds), + excludedModuleIds: hadHookRuntimeModule && Array.isArray(plan.excludedModuleIds) + ? [...new Set([...plan.excludedModuleIds, HOOK_RUNTIME_MODULE_ID])] + : plan.excludedModuleIds, + }; +} + +function withHookConsent(plan, hookConsent = null) { + if (hookConsent !== null && !HOOK_CONSENT_DECISIONS.includes(hookConsent)) { + throw new Error(`Unknown hook consent decision: ${hookConsent}`); + } + if (hookConsent === 'declined') { + return { ...stripHookRuntimeFromPlan(plan), hookConsent }; + } + return { + ...plan, + hookConsent, + statePreview: setStatePreviewHookConsent(plan.statePreview, hookConsent), + }; +} + +function assertHookConsentReady(plan = {}) { + if (!planMaterializesHookRuntime(plan)) { + return; + } + if (plan.hookConsent === 'enabled') { + return; + } + throw new Error( + 'This install would enable ECC\'s automatic hook runtime, which can:\n' + + `${formatHookCapabilityDisclosure()}\n` + + 'Confirm with --enable-hooks to install it, or --no-hooks to install ' + + 'everything else without the hook runtime. The guided installer ' + + '(ecc install --guided) collects this choice interactively.' + ); +} + +module.exports = { + HOOK_CAPABILITY_GROUPS, + assertHookConsentReady, + formatHookCapabilityDisclosure, + getRecordedHookConsent, + isHookRuntimeOperation, + planMaterializesHookRuntime, + resolveHookConsentFlags, + withHookConsent, +}; diff --git a/scripts/lib/install/request.js b/scripts/lib/install/request.js index d95b84ed5..f99f5aba1 100644 --- a/scripts/lib/install/request.js +++ b/scripts/lib/install/request.js @@ -1,6 +1,7 @@ 'use strict'; const { validateInstallModuleIds, LOCALE_ALIAS_TO_COMPONENT_ID, listSupportedLocales } = require('../install-manifests'); +const { resolveHookConsentFlags } = require('./hook-consent'); const LEGACY_INSTALL_TARGETS = ['claude', 'claude-project', 'cursor', 'antigravity']; @@ -28,6 +29,8 @@ function parseInstallArgs(argv) { excludeComponentIds: [], languages: [], locale: null, + enableHooks: false, + noHooks: false, }; for (let index = 0; index < args.length; index += 1) { @@ -68,6 +71,10 @@ function parseInstallArgs(argv) { } parsed.locale = locale; index += 1; + } else if (arg === '--enable-hooks') { + parsed.enableHooks = true; + } else if (arg === '--no-hooks') { + parsed.noHooks = true; } else if (arg === '--dry-run') { parsed.dryRun = true; } else if (arg === '--json') { @@ -119,6 +126,10 @@ function normalizeInstallRequest(options = {}) { ...(Array.isArray(options.legacyLanguages) ? options.legacyLanguages : []), ...(Array.isArray(options.languages) ? options.languages : []), ]).map(language => language.toLowerCase())); + const hookConsent = resolveHookConsentFlags(options); + if (hookConsent === 'declined' && moduleIds.includes('hooks-runtime')) { + throw new Error('--no-hooks cannot be combined with an explicit hooks-runtime module selection'); + } const hasManifestBaseSelection = Boolean(profileId) || moduleIds.length > 0 || includeComponentIds.length > 0; const hasNonLocaleManifestSelection = Boolean(profileId) || moduleIds.length > 0 @@ -146,6 +157,7 @@ function normalizeInstallRequest(options = {}) { includeComponentIds, excludeComponentIds, legacyLanguages, + hookConsent, configPath: config?.path || options.configPath || null, }; } diff --git a/scripts/lib/install/runtime.js b/scripts/lib/install/runtime.js index 1342814fb..eabb930b2 100644 --- a/scripts/lib/install/runtime.js +++ b/scripts/lib/install/runtime.js @@ -6,12 +6,17 @@ const { createManifestInstallPlan, } = require('../install-executor'); const { resolveInvocationEnvironment } = require('../invocation-environment'); +const { withHookConsent } = require('./hook-consent'); function createInstallPlanFromRequest(request, options = {}) { if (!request || typeof request !== 'object') { throw new Error('A normalized install request is required'); } + return withHookConsent(createRawInstallPlan(request, options), request.hookConsent || null); +} + +function createRawInstallPlan(request, options = {}) { if (request.mode === 'manifest') { return createManifestInstallPlan({ target: request.target, @@ -23,6 +28,7 @@ function createInstallPlanFromRequest(request, options = {}) { homeDir: options.homeDir, env: resolveInvocationEnvironment(options), sourceRoot: options.sourceRoot, + exemptValidationCodes: options.exemptValidationCodes || [], }); } @@ -37,6 +43,7 @@ function createInstallPlanFromRequest(request, options = {}) { env: resolveInvocationEnvironment(options), claudeRulesDir: options.claudeRulesDir, sourceRoot: options.sourceRoot, + exemptValidationCodes: options.exemptValidationCodes || [], }); } diff --git a/tests/lib/hook-consent.test.js b/tests/lib/hook-consent.test.js new file mode 100644 index 000000000..8749dd3d0 --- /dev/null +++ b/tests/lib/hook-consent.test.js @@ -0,0 +1,149 @@ +/** + * Tests for scripts/lib/install/hook-consent.js + */ + +const assert = require('assert'); + +const { + HOOK_CAPABILITY_GROUPS, + assertHookConsentReady, + formatHookCapabilityDisclosure, + isHookRuntimeOperation, + planMaterializesHookRuntime, + resolveHookConsentFlags, + withHookConsent, +} = require('../../scripts/lib/install/hook-consent'); + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + return false; + } +} + +function buildHookPlan() { + return { + operations: [ + { kind: 'copy-file', moduleId: 'rules-core', sourceRelativePath: 'rules/common.md', destinationPath: '/target/rules/common.md' }, + { kind: 'copy-file', moduleId: 'hooks-runtime', sourceRelativePath: 'hooks/hooks.json', destinationPath: '/target/hooks/hooks.json' }, + { kind: 'copy-file', moduleId: 'hooks-runtime', sourceRelativePath: 'scripts/hooks/session-start.js', destinationPath: '/target/scripts/hooks/session-start.js' }, + ], + selectedModuleIds: ['rules-core', 'hooks-runtime'], + excludedModuleIds: [], + statePreview: { + request: { + profile: 'core', + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + operations: [ + { kind: 'copy-file', moduleId: 'rules-core', sourceRelativePath: 'rules/common.md', destinationPath: '/target/rules/common.md' }, + { kind: 'copy-file', moduleId: 'hooks-runtime', sourceRelativePath: 'hooks/hooks.json', destinationPath: '/target/hooks/hooks.json' }, + ], + resolution: { selectedModules: ['rules-core', 'hooks-runtime'], skippedModules: [] }, + }, + }; +} + +function runTests() { + console.log('\n=== Testing install/hook-consent.js ===\n'); + + let passed = 0; + let failed = 0; + + if (test('declares six frozen capability groups with ids and descriptions', () => { + assert.strictEqual(HOOK_CAPABILITY_GROUPS.length, 6); + assert.ok(Object.isFrozen(HOOK_CAPABILITY_GROUPS)); + for (const group of HOOK_CAPABILITY_GROUPS) { + assert.ok(group.id && group.description); + } + })) passed++; else failed++; + + if (test('matches hook runtime operations by module id and source path', () => { + assert.strictEqual(isHookRuntimeOperation({ moduleId: 'hooks-runtime' }), true); + assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: 'hooks/hooks.json' }), true); + assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: '.cursor/hooks.json' }), true); + assert.strictEqual(isHookRuntimeOperation({ destinationPath: '/root/.claude/hooks/hooks.json' }), true); + assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: 'rules/common.md' }), false); + assert.strictEqual( + isHookRuntimeOperation({ sourceRelativePath: 'skills/webhooks-guide.md' }), + false + ); + })) passed++; else failed++; + + if (test('detects hook materialization from plan operations only', () => { + assert.strictEqual(planMaterializesHookRuntime(buildHookPlan()), true); + assert.strictEqual(planMaterializesHookRuntime({ + operations: [{ moduleId: 'rules-core', sourceRelativePath: 'rules/common.md' }], + selectedModuleIds: ['rules-core'], + }), false); + assert.strictEqual(planMaterializesHookRuntime({}), false); + })) passed++; else failed++; + + if (test('formats one numbered disclosure line per capability group', () => { + const disclosure = formatHookCapabilityDisclosure(); + const lines = disclosure.split('\n'); + assert.strictEqual(lines.length, HOOK_CAPABILITY_GROUPS.length); + assert.ok(lines[0].includes('1.')); + assert.ok(disclosure.includes('format or otherwise modify project source files')); + })) passed++; else failed++; + + if (test('resolves consent flags and rejects contradictions', () => { + assert.strictEqual(resolveHookConsentFlags({ enableHooks: true }), 'enabled'); + assert.strictEqual(resolveHookConsentFlags({ noHooks: true }), 'declined'); + assert.strictEqual(resolveHookConsentFlags({}), null); + assert.throws( + () => resolveHookConsentFlags({ enableHooks: true, noHooks: true }), + /mutually exclusive/ + ); + })) passed++; else failed++; + + if (test('withHookConsent attaches the decision without mutating enabled plans', () => { + const plan = buildHookPlan(); + const enabled = withHookConsent(plan, 'enabled'); + assert.strictEqual(enabled.hookConsent, 'enabled'); + assert.strictEqual(enabled.operations.length, 3); + assert.strictEqual(enabled.statePreview.request.hookConsent, 'enabled'); + const unset = withHookConsent(plan, null); + assert.strictEqual(unset.hookConsent, null); + assert.strictEqual(unset.statePreview.request.hookConsent, null); + assert.throws(() => withHookConsent(plan, 'maybe'), /Unknown hook consent decision/); + })) passed++; else failed++; + + if (test('declined consent strips the hook runtime from plan and state preview', () => { + const declined = withHookConsent(buildHookPlan(), 'declined'); + assert.strictEqual(declined.hookConsent, 'declined'); + assert.strictEqual(declined.operations.length, 1); + assert.deepStrictEqual(declined.selectedModuleIds, ['rules-core']); + assert.deepStrictEqual(declined.excludedModuleIds, ['hooks-runtime']); + assert.strictEqual(declined.statePreview.operations.length, 1); + assert.strictEqual(declined.statePreview.request.hookConsent, 'declined'); + assert.deepStrictEqual(declined.statePreview.resolution.selectedModules, ['rules-core']); + })) passed++; else failed++; + + if (test('assertHookConsentReady holds hook materialization without consent', () => { + assert.throws(() => assertHookConsentReady(buildHookPlan()), /automatic hook runtime/); + assert.throws( + () => assertHookConsentReady(buildHookPlan()), + /--enable-hooks/ + ); + assert.doesNotThrow(() => assertHookConsentReady(withHookConsent(buildHookPlan(), 'enabled'))); + assert.doesNotThrow(() => assertHookConsentReady({ + operations: [{ moduleId: 'rules-core', sourceRelativePath: 'rules/common.md' }], + })); + assert.doesNotThrow(() => assertHookConsentReady(withHookConsent(buildHookPlan(), 'declined'))); + })) passed++; else failed++; + + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +runTests(); diff --git a/tests/lib/install-executor.test.js b/tests/lib/install-executor.test.js index 2a0026d9e..4a65ce5ef 100644 --- a/tests/lib/install-executor.test.js +++ b/tests/lib/install-executor.test.js @@ -665,6 +665,7 @@ function runTests() { installRoot: targetRoot, installStatePath: path.join(targetRoot, 'ecc', 'install-state.json'), warnings: [], + hookConsent: 'enabled', statePreview: { target: 'claude', adapter: { id: 'claude-home', target: 'claude', kind: 'home' }, diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index e4852da42..51d39f9e1 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -16,6 +16,7 @@ const { uninstallInstalledStates, } = require('../../scripts/lib/install-lifecycle'); const { applyInstallPlan } = require('../../scripts/lib/install/apply'); +const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime'); const { getInstallTargetAdapter } = require('../../scripts/lib/install-targets/registry'); const { createInstallState, @@ -1831,6 +1832,81 @@ function runTests() { } })) passed++; else failed++; + if (test('doctor honors a recorded declined hook decision for manifest installs', () => { + const homeDir = createTempDir('install-lifecycle-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const plan = createInstallPlanFromRequest({ + mode: 'manifest', + target: 'cursor', + profileId: 'core', + moduleIds: [], + includeComponentIds: [], + excludeComponentIds: [], + legacyLanguages: [], + hookConsent: 'declined', + }, { + sourceRoot: REPO_ROOT, + projectRoot, + homeDir, + }); + + writeInstallState(plan.installStatePath, plan.statePreview); + + const report = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['cursor'], + }); + + assert.strictEqual(report.results.length, 1); + assert.ok(!report.results[0].issues.some(issue => issue.code === 'resolution-drift')); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + + if (test('doctor infers enabled hooks from older manifest install-state records', () => { + const homeDir = createTempDir('install-lifecycle-home-'); + const projectRoot = createTempDir('install-lifecycle-project-'); + + try { + const plan = createInstallPlanFromRequest({ + mode: 'manifest', + target: 'cursor', + profileId: 'core', + moduleIds: [], + includeComponentIds: [], + excludeComponentIds: [], + legacyLanguages: [], + hookConsent: 'enabled', + }, { + sourceRoot: REPO_ROOT, + projectRoot, + homeDir, + }); + const legacyState = JSON.parse(JSON.stringify(plan.statePreview)); + delete legacyState.request.hookConsent; + writeInstallState(plan.installStatePath, legacyState); + + const report = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir, + projectRoot, + targets: ['cursor'], + }); + + assert.strictEqual(report.results.length, 1); + assert.ok(!report.results[0].issues.some(issue => issue.code === 'resolution-drift')); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('repair restores render-template outputs from recorded rendered content', () => { const homeDir = createTempDir('install-lifecycle-home-'); const projectRoot = createTempDir('install-lifecycle-project-'); diff --git a/tests/lib/install-request.test.js b/tests/lib/install-request.test.js index 614c8ee26..9e580918c 100644 --- a/tests/lib/install-request.test.js +++ b/tests/lib/install-request.test.js @@ -63,6 +63,26 @@ function runTests() { assert.deepStrictEqual(parsed.languages, []); })) passed++; else failed++; + if (test('parses explicit hook consent flags', () => { + const enabled = parseInstallArgs([ + 'node', + 'scripts/install-apply.js', + '--profile', 'core', + '--enable-hooks', + ]); + const declined = parseInstallArgs([ + 'node', + 'scripts/install-apply.js', + '--profile', 'core', + '--no-hooks', + ]); + + assert.strictEqual(enabled.enableHooks, true); + assert.strictEqual(enabled.noHooks, false); + assert.strictEqual(declined.enableHooks, false); + assert.strictEqual(declined.noHooks, true); + })) passed++; else failed++; + if (test('requires a --locale value', () => { assert.throws( () => parseInstallArgs([ @@ -160,12 +180,14 @@ function runTests() { moduleIds: [], includeComponentIds: ['lang:typescript'], excludeComponentIds: ['capability:media'], - languages: [] + languages: [], + enableHooks: true, }); assert.strictEqual(request.mode, 'manifest'); assert.strictEqual(request.target, 'cursor'); assert.strictEqual(request.profileId, 'developer'); + assert.strictEqual(request.hookConsent, 'enabled'); assert.deepStrictEqual(request.includeComponentIds, ['lang:typescript']); assert.deepStrictEqual(request.excludeComponentIds, ['capability:media']); assert.deepStrictEqual(request.legacyLanguages, []); @@ -227,6 +249,21 @@ function runTests() { ); })) passed++; else failed++; + if (test('rejects --no-hooks with an explicit hooks-runtime selection', () => { + assert.throws( + () => normalizeInstallRequest({ + target: 'claude', + profileId: null, + moduleIds: ['hooks-runtime'], + includeComponentIds: [], + excludeComponentIds: [], + languages: [], + noHooks: true, + }), + /--no-hooks cannot be combined/ + ); + })) passed++; else failed++; + if (test('rejects empty install requests when not asking for help', () => { assert.throws( () => normalizeInstallRequest({ diff --git a/tests/lib/install-state.test.js b/tests/lib/install-state.test.js index 01011f6a6..8baa6c3e5 100644 --- a/tests/lib/install-state.test.js +++ b/tests/lib/install-state.test.js @@ -52,6 +52,7 @@ function runTests() { modules: ['orchestration'], legacyLanguages: ['typescript'], legacyMode: true, + hookConsent: 'declined', }, resolution: { selectedModules: ['rules-core', 'orchestration'], @@ -79,6 +80,7 @@ function runTests() { assert.strictEqual(state.schemaVersion, 'ecc.install.v1'); assert.strictEqual(state.target.id, 'cursor-project'); assert.strictEqual(state.request.profile, 'developer'); + assert.strictEqual(state.request.hookConsent, 'declined'); assert.strictEqual(state.operations.length, 1); })) passed++; else failed++; diff --git a/tests/lib/selective-install.test.js b/tests/lib/selective-install.test.js index 97c2e5bc8..040103bf7 100644 --- a/tests/lib/selective-install.test.js +++ b/tests/lib/selective-install.test.js @@ -649,6 +649,7 @@ function runTests() { scriptPath, '--profile', 'core', '--with', 'capability:security', + '--enable-hooks', ], { cwd: projectDir, env: { ...process.env, HOME: homeDir }, @@ -668,6 +669,7 @@ function runTests() { const statePath = path.join(claudeRoot, 'ecc', 'install-state.json'); const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); assert.strictEqual(state.request.profile, 'core'); + assert.strictEqual(state.request.hookConsent, 'enabled'); assert.deepStrictEqual(state.request.includeComponents, ['capability:security']); assert.deepStrictEqual(state.request.excludeComponents, []); assert.ok(state.resolution.selectedModules.includes('security')); @@ -688,6 +690,7 @@ function runTests() { scriptPath, '--profile', 'developer', '--without', 'capability:orchestration', + '--enable-hooks', ], { cwd: projectDir, env: { ...process.env, HOME: homeDir }, @@ -708,6 +711,7 @@ function runTests() { const statePath = path.join(claudeRoot, 'ecc', 'install-state.json'); const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); assert.strictEqual(state.request.profile, 'developer'); + assert.strictEqual(state.request.hookConsent, 'enabled'); assert.deepStrictEqual(state.request.excludeComponents, ['capability:orchestration']); assert.ok(!state.resolution.selectedModules.includes('orchestration')); } finally { diff --git a/tests/scripts/auto-update.test.js b/tests/scripts/auto-update.test.js index 2479f7301..9533fd67b 100644 --- a/tests/scripts/auto-update.test.js +++ b/tests/scripts/auto-update.test.js @@ -169,6 +169,7 @@ function runTests() { excludeComponents: ['component:beta'], legacyLanguages: [], legacyMode: false, + hookConsent: 'declined', }, }, }; @@ -179,6 +180,42 @@ function runTests() { '--modules', 'platform-configs', '--with', 'component:alpha', '--without', 'component:beta', + '--no-hooks', + ]); + })) passed += 1; else failed += 1; + + if (test('buildInstallApplyArgs infers enabled hooks for older install-state records', () => { + const record = { + adapter: { target: 'cursor', kind: 'project' }, + state: { + target: { target: 'cursor' }, + request: { + profile: 'core', + modules: [], + includeComponents: [], + excludeComponents: [], + legacyLanguages: [], + legacyMode: false, + }, + resolution: { + selectedModules: ['rules-core', 'hooks-runtime'], + skippedModules: [], + }, + operations: [ + { + kind: 'copy-file', + moduleId: 'hooks-runtime', + sourceRelativePath: '.cursor/hooks.json', + destinationPath: '/tmp/project/.cursor/hooks.json', + }, + ], + }, + }; + + assert.deepStrictEqual(buildInstallApplyArgs(record), [ + '--target', 'cursor', + '--profile', 'core', + '--enable-hooks', ]); })) passed += 1; else failed += 1; diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index e011a572f..0931d5c0a 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -135,7 +135,7 @@ function runTests() { const projectDir = createTempDir('install-apply-project-'); try { - const result = run(['typescript'], { cwd: projectDir, homeDir }); + const result = run(['typescript', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); const claudeRoot = path.join(homeDir, '.claude'); @@ -173,7 +173,7 @@ function runTests() { const projectDir = createTempDir('install-apply-project-'); try { - const result = run(['typescript'], { cwd: projectDir, homeDir }); + const result = run(['typescript', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); const claudeRoot = path.join(homeDir, '.claude'); @@ -207,7 +207,7 @@ function runTests() { const projectDir = createTempDir('install-apply-project-'); try { - const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir }); + const result = run(['--target', 'cursor', 'typescript', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); assert.ok(fs.existsSync(path.join(projectDir, '.cursor', 'rules', 'common-coding-style.mdc'))); @@ -267,7 +267,7 @@ function runTests() { }, }, null, 2)); - const result = run(['--target', 'cursor', 'typescript'], { cwd: projectDir, homeDir }); + const result = run(['--target', 'cursor', 'typescript', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); const mcpConfig = readJson(path.join(projectDir, '.cursor', 'mcp.json')); @@ -525,7 +525,7 @@ function runTests() { const projectDir = createTempDir('install-apply-project-'); try { - const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); const claudeRoot = path.join(homeDir, '.claude'); @@ -567,7 +567,7 @@ function runTests() { fs.writeFileSync(userRulePath, '# User custom rule\n'); fs.writeFileSync(userSkillPath, '# User custom skill\n'); - const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); assert.ok(result.stdout.includes('user-owned'), result.stdout); assert.ok(result.stdout.includes('Skipped operations:'), result.stdout); @@ -715,7 +715,7 @@ function runTests() { const projectDir = createTempDir('install-apply-project-'); try { - const result = run(['--target', 'cursor', '--modules', 'platform-configs'], { + const result = run(['--target', 'cursor', '--modules', 'platform-configs', '--enable-hooks'], { cwd: projectDir, homeDir, }); @@ -752,7 +752,7 @@ function runTests() { const projectDir = createTempDir('install-apply-project-'); try { - const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); const claudeRoot = path.join(homeDir, '.claude'); @@ -772,7 +772,7 @@ function runTests() { const projectDir = createTempDir('install-apply-project-'); try { - const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); const claudeRoot = path.join(homeDir, '.claude'); @@ -830,7 +830,7 @@ function runTests() { }, null, 2) ); - const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); const settings = readJson(path.join(claudeRoot, 'settings.json')); @@ -932,10 +932,10 @@ function runTests() { const projectDir = createTempDir('install-apply-project-'); try { - const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const firstInstall = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(firstInstall.code, 0, firstInstall.stderr); - const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const secondInstall = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); assert.deepStrictEqual( @@ -963,7 +963,7 @@ function runTests() { }; fs.writeFileSync(settingsPath, JSON.stringify(legacySettings, null, 2)); - const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const secondInstall = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); const afterSecondInstall = readJson(settingsPath); @@ -991,7 +991,7 @@ function runTests() { }; fs.writeFileSync(settingsPath, JSON.stringify(customSettings, null, 2)); - const install = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const install = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(install.code, 0, install.stderr); const afterInstall = readJson(settingsPath); @@ -1018,7 +1018,7 @@ function runTests() { }; fs.writeFileSync(settingsPath, JSON.stringify(customSettings, null, 2)); - const install = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const install = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(install.code, 0, install.stderr); const afterInstall = readJson(settingsPath); @@ -1039,7 +1039,7 @@ function runTests() { const settingsPath = path.join(claudeRoot, 'settings.json'); fs.writeFileSync(settingsPath, '{ invalid json\n'); - const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n'); assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied'); @@ -1060,7 +1060,7 @@ function runTests() { const settingsPath = path.join(claudeRoot, 'settings.json'); fs.writeFileSync(settingsPath, '[]\n'); - const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + const result = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '[]\n'); assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should still be copied'); @@ -1084,6 +1084,7 @@ function runTests() { applyInstallPlan({ targetRoot, installStatePath, + hookConsent: 'enabled', statePreview: { schemaVersion: 'ecc.install.v1', installedAt: new Date().toISOString(), @@ -1147,7 +1148,7 @@ function runTests() { exclude: ['capability:orchestration'], }, null, 2)); - const result = run(['--config', configPath], { cwd: projectDir, homeDir }); + const result = run(['--config', configPath, '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md'))); @@ -1179,7 +1180,7 @@ function runTests() { exclude: ['capability:orchestration'], }, null, 2)); - const result = run([], { cwd: projectDir, homeDir }); + const result = run(['--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); assert.ok(fs.existsSync(path.join(homeDir, '.claude', 'skills', 'security-review', 'SKILL.md'))); @@ -1210,7 +1211,7 @@ function runTests() { include: ['capability:security'], }, null, 2)); - const result = run(['typescript'], { cwd: projectDir, homeDir }); + const result = run(['typescript', '--enable-hooks'], { cwd: projectDir, homeDir }); assert.strictEqual(result.code, 0, result.stderr); const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json')); @@ -1226,6 +1227,58 @@ function runTests() { } })) passed++; else failed++; + if (test('holds hook materialization without an explicit hook decision', () => { + const projectDir = createTempDir('install-apply-consent-held-'); + const homeDir = createTempDir('install-apply-consent-held-home-'); + try { + const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + assert.notStrictEqual(result.code, 0); + assert.ok(result.stderr.includes('automatic hook runtime')); + assert.ok(result.stderr.includes('--enable-hooks')); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'hooks', 'hooks.json'))); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'ecc', 'install-state.json'))); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('--no-hooks installs the profile without the hook runtime', () => { + const projectDir = createTempDir('install-apply-no-hooks-'); + const homeDir = createTempDir('install-apply-no-hooks-home-'); + try { + const result = run(['--profile', 'core', '--no-hooks'], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(!fs.existsSync(path.join(homeDir, '.claude', 'hooks', 'hooks.json'))); + const state = readJson(path.join(homeDir, '.claude', 'ecc', 'install-state.json')); + assert.strictEqual(state.request.hookConsent, 'declined'); + assert.ok(!state.resolution.selectedModules.includes('hooks-runtime')); + assert.ok(state.resolution.selectedModules.includes('rules-core')); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('rejects --enable-hooks combined with --no-hooks', () => { + const result = run(['--profile', 'core', '--enable-hooks', '--no-hooks']); + assert.notStrictEqual(result.code, 0); + assert.ok(result.stderr.includes('mutually exclusive')); + })) passed++; else failed++; + + if (test('dry-run surfaces the pending hook decision as a warning', () => { + const projectDir = createTempDir('install-apply-consent-dry-'); + const homeDir = createTempDir('install-apply-consent-dry-home-'); + try { + const result = run(['--profile', 'core', '--dry-run'], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + assert.ok(result.stdout.includes('explicit hook decision')); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); } diff --git a/tests/scripts/install-guided.test.js b/tests/scripts/install-guided.test.js index 24e941dbb..661756672 100644 --- a/tests/scripts/install-guided.test.js +++ b/tests/scripts/install-guided.test.js @@ -8,6 +8,7 @@ const { collectInteractiveOptions, main, parseArgs, + printPlan, validateExecutionMode, } = require('../../scripts/install-guided'); const { @@ -361,6 +362,28 @@ function runGuidedPtyFixture(answers) { assert.doesNotMatch(errorOutput.read(), /\[31m/); }); + await test('printPlan discloses hook capabilities for non-off Claude hook profiles', async () => { + let written = ''; + const output = { write: chunk => { written += chunk; } }; + printPlan({ + harnesses: [{ id: 'claude', channel: 'native-plugin' }], + request: { harnesses: ['claude'], claudeHooks: 'standard' }, + }, output); + assert.ok(written.includes("hook profile 'standard'")); + assert.ok(written.includes('modify project source files')); + assert.ok(written.includes("--claude-hooks off")); + }); + + await test('printPlan omits the hook disclosure when Claude hooks are off', async () => { + let written = ''; + const output = { write: chunk => { written += chunk; } }; + printPlan({ + harnesses: [{ id: 'claude', channel: 'native-plugin' }], + request: { harnesses: ['claude'], claudeHooks: 'off' }, + }, output); + assert.ok(!written.includes('enables automation')); + }); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exitCode = failed > 0 ? 1 : 0; })(); diff --git a/tests/scripts/install-readme-clarity.test.js b/tests/scripts/install-readme-clarity.test.js index 4b48496c7..5d7f5c3e5 100644 --- a/tests/scripts/install-readme-clarity.test.js +++ b/tests/scripts/install-readme-clarity.test.js @@ -126,6 +126,10 @@ function runTests() { readme.includes('--profile core --without baseline:hooks --target claude'), 'README should document the hook opt-out path for the core profile' ); + assert.ok( + readme.includes('./install.sh --profile core --no-hooks --target claude'), + 'README should document the explicit no-hooks consent path for the core profile' + ); assert.ok( readme.includes('This profile intentionally excludes `hooks-runtime`.'), 'README should state that the minimal profile excludes hooks' diff --git a/tests/scripts/manual-hook-install-docs.test.js b/tests/scripts/manual-hook-install-docs.test.js index 271c5d3ff..8dc531efd 100644 --- a/tests/scripts/manual-hook-install-docs.test.js +++ b/tests/scripts/manual-hook-install-docs.test.js @@ -36,11 +36,11 @@ function runTests() { 'README should warn against unsupported raw hook copying' ); assert.ok( - readme.includes('bash ./install.sh --target claude --modules hooks-runtime'), + readme.includes('bash ./install.sh --target claude --modules hooks-runtime --enable-hooks'), 'README should document the supported Bash hook install path' ); assert.ok( - readme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'), + readme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime --enable-hooks'), 'README should document the supported PowerShell hook install path' ); assert.ok( @@ -55,11 +55,11 @@ function runTests() { 'hooks/README should warn against unsupported raw hook copying' ); assert.ok( - hooksReadme.includes('bash ./install.sh --target claude --modules hooks-runtime'), + hooksReadme.includes('bash ./install.sh --target claude --modules hooks-runtime --enable-hooks'), 'hooks/README should document the supported Bash hook install path' ); assert.ok( - hooksReadme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime'), + hooksReadme.includes('pwsh -File .\\install.ps1 --target claude --modules hooks-runtime --enable-hooks'), 'hooks/README should document the supported PowerShell hook install path' ); })) passed++; else failed++; diff --git a/tests/scripts/repair.test.js b/tests/scripts/repair.test.js index cbd80a15e..0ccb2ac1a 100644 --- a/tests/scripts/repair.test.js +++ b/tests/scripts/repair.test.js @@ -98,7 +98,7 @@ function runTests() { const projectRoot = createTempDir('repair-project-'); try { - const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', 'typescript'], { + const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', 'typescript', '--enable-hooks'], { cwd: projectRoot, homeDir, }); @@ -137,6 +137,52 @@ function runTests() { } })) passed++; else failed++; + if (test('repair preserves a declined hook decision and does not reinstall hooks', () => { + const homeDir = createTempDir('repair-home-'); + const projectRoot = createTempDir('repair-project-'); + + try { + const installResult = runNode(INSTALL_SCRIPT, ['--target', 'cursor', '--profile', 'core', '--no-hooks'], { + cwd: projectRoot, + homeDir, + }); + assert.strictEqual(installResult.code, 0, installResult.stderr); + + const normalizedProjectRoot = fs.realpathSync(projectRoot); + const managedPath = path.join(normalizedProjectRoot, '.cursor', 'rules', 'common-coding-style.mdc'); + const statePath = path.join(normalizedProjectRoot, '.cursor', 'ecc-install-state.json'); + const hooksConfigPath = path.join(normalizedProjectRoot, '.cursor', 'hooks.json'); + const expectedContent = fs.readFileSync(managedPath, 'utf8'); + fs.rmSync(managedPath, { force: true }); + + const doctorBefore = runNode(DOCTOR_SCRIPT, ['--target', 'cursor', '--json'], { + cwd: projectRoot, + homeDir, + }); + assert.strictEqual(doctorBefore.code, 1); + assert.ok(JSON.parse(doctorBefore.stdout).results[0].issues.some(issue => issue.code === 'missing-managed-files')); + + const repairResult = runNode(REPAIR_SCRIPT, ['--target', 'cursor', '--json'], { + cwd: projectRoot, + homeDir, + }); + assert.strictEqual(repairResult.code, 0, repairResult.stderr); + + const parsed = JSON.parse(repairResult.stdout); + assert.strictEqual(parsed.results[0].status, 'repaired'); + assert.ok(pathListIncludes(parsed.results[0].repairedPaths, managedPath)); + assert.strictEqual(fs.readFileSync(managedPath, 'utf8'), expectedContent); + assert.ok(!fs.existsSync(hooksConfigPath)); + + const repairedState = JSON.parse(fs.readFileSync(statePath, 'utf8')); + assert.strictEqual(repairedState.request.hookConsent, 'declined'); + assert.ok(!repairedState.resolution.selectedModules.includes('hooks-runtime')); + } finally { + cleanup(homeDir); + cleanup(projectRoot); + } + })) passed++; else failed++; + if (test('repairs drifted non-copy managed operations and refreshes install-state', () => { const homeDir = createTempDir('repair-home-'); const projectRoot = createTempDir('repair-project-'); diff --git a/tests/scripts/uninstall.test.js b/tests/scripts/uninstall.test.js index 1a1687f00..646a2b318 100644 --- a/tests/scripts/uninstall.test.js +++ b/tests/scripts/uninstall.test.js @@ -90,7 +90,7 @@ function runTests() { const projectRoot = createTempDir('uninstall-project-'); try { - const installStdout = execFileSync('node', [INSTALL_SCRIPT, '--target', 'cursor', 'typescript'], { + const installStdout = execFileSync('node', [INSTALL_SCRIPT, '--target', 'cursor', 'typescript', '--enable-hooks'], { cwd: projectRoot, env: { ...process.env,