mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-31 04:09:51 +02:00
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:
co-authored by
Samarjeet Singh Tomar
Claude Fable 5
parent
a89cec9658
commit
6aaa41e028
@@ -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.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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`.
|
||||
|
||||
@@ -107,6 +107,13 @@
|
||||
},
|
||||
"legacyMode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hookConsent": {
|
||||
"enum": [
|
||||
"enabled",
|
||||
"declined",
|
||||
null
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -547,6 +547,7 @@ function createLegacyCompatInstallPlan(options = {}) {
|
||||
legacyLanguages: selection.legacyLanguages,
|
||||
ruleLanguages: selection.ruleLanguages,
|
||||
legacyMode: true,
|
||||
exemptValidationCodes: options.exemptValidationCodes || [],
|
||||
requestProfileId: null,
|
||||
requestModuleIds: [],
|
||||
requestIncludeComponentIds: includeComponentIds,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 || [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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' },
|
||||
|
||||
@@ -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-');
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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++;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
})();
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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-');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user