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>
This commit is contained in:
haelyra
2026-08-09 17:16:03 -04:00
co-authored by Samarjeet Singh Tomar Claude Fable 5
parent 649def769b
commit f0cea4f3df
14 changed files with 436 additions and 21 deletions
+7 -1
View File
@@ -361,13 +361,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.
</details>
<details>
+3
View File
@@ -58,6 +58,9 @@ Options:
--locale <code> Install translated docs to ~/.claude/docs/<locale>/ (or ./.claude/docs/<locale>/ for claude-project)
(claude or claude-project target only; can be combined with --profile or --with)
--config <path> 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
+8
View File
@@ -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) {
+6
View File
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path');
const { writeInstallState } = require('../install-state');
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config');
const { assertWithinTrustedRoot } = require('../path-safety');
const {
@@ -209,6 +210,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,
@@ -218,12 +222,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 beforeOperationWrite = dependencies.beforeOperationWrite;
const beforeInstallStateWrite = dependencies.beforeInstallStateWrite;
+160
View File
@@ -0,0 +1,160 @@
'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']);
function normalizeOperationPath(value) {
return String(value || '').replace(/\\/g, '/').toLowerCase();
}
function isHookRuntimeOperation(operation = {}) {
if (operation.moduleId === 'hooks-runtime') {
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 !== 'hooks-runtime');
}
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,
selectedModuleIds: withoutHookRuntimeId(plan.selectedModuleIds),
excludedModuleIds: hadHookRuntimeModule && Array.isArray(plan.excludedModuleIds)
? [...new Set([...plan.excludedModuleIds, 'hooks-runtime'])]
: 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 };
}
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,
isHookRuntimeOperation,
planMaterializesHookRuntime,
resolveHookConsentFlags,
withHookConsent,
};
+12
View File
@@ -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,
};
}
+5
View File
@@ -5,12 +5,17 @@ const {
createLegacyInstallPlan,
createManifestInstallPlan,
} = require('../install-executor');
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,
+138
View File
@@ -0,0 +1,138 @@
/**
* 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: {
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);
const unset = withHookConsent(plan, null);
assert.strictEqual(unset.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.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();
+1
View File
@@ -643,6 +643,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' },
+2
View File
@@ -649,6 +649,7 @@ function runTests() {
scriptPath,
'--profile', 'core',
'--with', 'capability:security',
'--enable-hooks',
], {
cwd: projectDir,
env: { ...process.env, HOME: homeDir },
@@ -688,6 +689,7 @@ function runTests() {
scriptPath,
'--profile', 'developer',
'--without', 'capability:orchestration',
'--enable-hooks',
], {
cwd: projectDir,
env: { ...process.env, HOME: homeDir },
+69 -18
View File
@@ -127,7 +127,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');
@@ -165,7 +165,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');
@@ -199,7 +199,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')));
@@ -259,7 +259,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'));
@@ -460,7 +460,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');
@@ -502,7 +502,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);
@@ -649,7 +649,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,
});
@@ -686,7 +686,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');
@@ -703,7 +703,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');
@@ -761,7 +761,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'));
@@ -862,10 +862,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.ok(!fs.existsSync(path.join(homeDir, '.claude', 'settings.json')));
@@ -890,7 +890,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);
@@ -911,7 +911,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');
@@ -932,7 +932,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');
@@ -956,6 +956,7 @@ function runTests() {
applyInstallPlan({
targetRoot,
installStatePath,
hookConsent: 'enabled',
statePreview: {
schemaVersion: 'ecc.install.v1',
installedAt: new Date().toISOString(),
@@ -1019,7 +1020,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')));
@@ -1051,7 +1052,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')));
@@ -1082,7 +1083,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'));
@@ -1098,6 +1099,56 @@ 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')));
} 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.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);
}
+23
View File
@@ -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;
})();
+1 -1
View File
@@ -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,
});
+1 -1
View File
@@ -85,7 +85,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,