diff --git a/scripts/lib/install-lifecycle.js b/scripts/lib/install-lifecycle.js index c10b1cfe3..1bc36fe6e 100644 --- a/scripts/lib/install-lifecycle.js +++ b/scripts/lib/install-lifecycle.js @@ -8,7 +8,10 @@ const { loadInstallManifests } = require('./install-manifests'); const { readInstallState, validateInstallState } = require('./install-state'); const { assertWithinTrustedRoot } = require('./path-safety'); const { createInstallPlanFromRequest } = require('./install/runtime'); -const { getRecordedHookConsent } = require('./install/hook-consent'); +const { + disableOpenCodeHookPluginRegistration, + getRecordedHookConsent, +} = require('./install/hook-consent'); const { prepareClaudeSkillMigration, } = require('./install/claude-skill-migration'); @@ -217,6 +220,9 @@ function transformCopyFileContent(operation, content) { if (operation.contentTransform === 'antigravity-agent-frontmatter') { return adaptAntigravityAgent(content, operation.sourceRelativePath); } + if (operation.contentTransform === 'opencode-disable-ecc-hooks') { + return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath); + } throw new Error(`Unknown install content transform: ${operation.contentTransform}`); } diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index e755586a8..d5870feaf 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -9,7 +9,11 @@ const { withCommitAttributionDisabled, } = require('../claude-commit-attribution'); const { writeInstallState } = require('../install-state'); -const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent'); +const { + assertHookConsentReady, + disableOpenCodeHookPluginRegistration, + planMaterializesHookRuntime, +} = require('./hook-consent'); const { filterMcpConfig, parseDisabledMcpServers } = require('../mcp-config'); const { assertWithinTrustedRoot } = require('../path-safety'); const { @@ -33,6 +37,9 @@ function transformInstallContent(operation, content) { if (operation.contentTransform === 'antigravity-agent-frontmatter') { return adaptAntigravityAgent(content, operation.sourceRelativePath); } + if (operation.contentTransform === 'opencode-disable-ecc-hooks') { + return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath); + } throw new Error(`Unknown install content transform: ${operation.contentTransform}`); } diff --git a/scripts/lib/install/hook-consent.js b/scripts/lib/install/hook-consent.js index f12bd833c..ea382af1d 100644 --- a/scripts/lib/install/hook-consent.js +++ b/scripts/lib/install/hook-consent.js @@ -38,16 +38,52 @@ const HOOK_CAPABILITY_GROUPS = Object.freeze([ const HOOK_CONSENT_DECISIONS = Object.freeze(['enabled', 'declined']); const HOOK_RUNTIME_MODULE_ID = 'hooks-runtime'; +const OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM = 'opencode-disable-ecc-hooks'; function normalizeOperationPath(value) { return String(value || '').replace(/\\/g, '/').toLowerCase(); } +function disableOpenCodeHookPluginRegistration(content, sourceRelativePath) { + let config; + try { + config = JSON.parse(content); + } catch (error) { + throw new Error(`Failed to parse ${sourceRelativePath}: ${error.message}`); + } + if (!config || typeof config !== 'object' || Array.isArray(config)) { + throw new Error(`Invalid ${sourceRelativePath}: expected a JSON object`); + } + if (config.plugin !== undefined && !Array.isArray(config.plugin)) { + throw new Error(`Invalid ${sourceRelativePath}: plugin must be an array`); + } + + if (!Array.isArray(config.plugin)) { + return `${JSON.stringify(config, null, 2)}\n`; + } + + return `${JSON.stringify({ + ...config, + plugin: config.plugin.filter(plugin => plugin !== './plugins'), + }, null, 2)}\n`; +} + +function isOpenCodeHookActivationOperation(operation = {}) { + return normalizeOperationPath(operation.sourceRelativePath) === '.opencode/opencode.json'; +} + function isHookRuntimeOperation(operation = {}) { if (operation.moduleId === HOOK_RUNTIME_MODULE_ID) { return true; } + if (isOpenCodeHookActivationOperation(operation)) { + return !( + operation.kind === 'copy-file' + && operation.contentTransform === OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM + ); + } + const source = normalizeOperationPath(operation.sourceRelativePath); const destination = normalizeOperationPath(operation.destinationPath); return ( @@ -90,6 +126,50 @@ function withoutHookRuntimeId(values) { return (Array.isArray(values) ? values : []).filter(value => value !== HOOK_RUNTIME_MODULE_ID); } +function withoutOpenCodeHookActivation(operation) { + if ( + !isOpenCodeHookActivationOperation(operation) + || operation.kind !== 'copy-file' + ) { + return operation; + } + return { + ...operation, + contentTransform: OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM, + }; +} + +function transformOpenCodeHookActivationOperations(operations) { + return (Array.isArray(operations) ? operations : []).map(withoutOpenCodeHookActivation); +} + +function planSelectsHookRuntime(plan = {}) { + return ( + Array.isArray(plan.selectedModuleIds) + && plan.selectedModuleIds.includes(HOOK_RUNTIME_MODULE_ID) + ) || ( + Array.isArray(plan.operations) + && plan.operations.some(operation => operation.moduleId === HOOK_RUNTIME_MODULE_ID) + ); +} + +function disableUnselectedOpenCodeHooks(plan) { + if (plan.target !== 'opencode' || planSelectsHookRuntime(plan)) { + return plan; + } + + return { + ...plan, + operations: transformOpenCodeHookActivationOperations(plan.operations), + statePreview: plan.statePreview + ? { + ...plan.statePreview, + operations: transformOpenCodeHookActivationOperations(plan.statePreview.operations), + } + : plan.statePreview, + }; +} + function setStatePreviewHookConsent(statePreview, hookConsent) { if (!statePreview || !statePreview.request) { return statePreview; @@ -131,11 +211,17 @@ function stripHookRuntimeFromPlan(plan) { const hadHookRuntimeModule = Array.isArray(plan.selectedModuleIds) && plan.selectedModuleIds.includes('hooks-runtime'); const operations = (Array.isArray(plan.operations) ? plan.operations : []) + .map(operation => ( + plan.target === 'opencode' ? withoutOpenCodeHookActivation(operation) : operation + )) .filter(operation => !isHookRuntimeOperation(operation)); const statePreview = plan.statePreview ? { ...plan.statePreview, operations: (Array.isArray(plan.statePreview.operations) ? plan.statePreview.operations : []) + .map(operation => ( + plan.target === 'opencode' ? withoutOpenCodeHookActivation(operation) : operation + )) .filter(operation => !isHookRuntimeOperation(operation)), resolution: plan.statePreview.resolution ? { @@ -164,10 +250,11 @@ function withHookConsent(plan, hookConsent = null) { if (hookConsent === 'declined') { return { ...stripHookRuntimeFromPlan(plan), hookConsent }; } + const effectivePlan = disableUnselectedOpenCodeHooks(plan); return { - ...plan, + ...effectivePlan, hookConsent, - statePreview: setStatePreviewHookConsent(plan.statePreview, hookConsent), + statePreview: setStatePreviewHookConsent(effectivePlan.statePreview, hookConsent), }; } @@ -190,6 +277,8 @@ function assertHookConsentReady(plan = {}) { module.exports = { HOOK_CAPABILITY_GROUPS, assertHookConsentReady, + disableUnselectedOpenCodeHooks, + disableOpenCodeHookPluginRegistration, formatHookCapabilityDisclosure, getRecordedHookConsent, isHookRuntimeOperation, diff --git a/scripts/lib/install/plan.js b/scripts/lib/install/plan.js index d98ef8f0b..1d7738c77 100644 --- a/scripts/lib/install/plan.js +++ b/scripts/lib/install/plan.js @@ -7,6 +7,7 @@ const { execFileSync } = require('child_process'); const { resolveInstallPlan } = require('../install-manifests'); const { getInstallTargetAdapter } = require('../install-targets/registry'); const { resolveInvocationEnvironment } = require('../invocation-environment'); +const { disableUnselectedOpenCodeHooks } = require('./hook-consent'); const EXCLUDED_GENERATED_SOURCE_SUFFIXES = ['/ecc-install-state.json', '/ecc/install-state.json']; const IGNORED_DIRECTORY_NAMES = new Set([ @@ -285,7 +286,7 @@ function createManifestInstallPlan(options = {}) { source }); - return { + return disableUnselectedOpenCodeHooks({ mode: options.mode || 'manifest', sourceRoot, target, @@ -311,7 +312,7 @@ function createManifestInstallPlan(options = {}) { excludedModuleIds: plan.excludedModuleIds, operations, statePreview - }; + }); } module.exports = { diff --git a/tests/lib/hook-consent.test.js b/tests/lib/hook-consent.test.js index 716a91b3a..f3f0e6d9a 100644 --- a/tests/lib/hook-consent.test.js +++ b/tests/lib/hook-consent.test.js @@ -7,6 +7,7 @@ const assert = require('assert'); const { HOOK_CAPABILITY_GROUPS, assertHookConsentReady, + disableOpenCodeHookPluginRegistration, formatHookCapabilityDisclosure, isHookRuntimeOperation, planMaterializesHookRuntime, @@ -80,6 +81,23 @@ function runTests() { }), false ); + assert.strictEqual(isHookRuntimeOperation({ + kind: 'copy-file', + moduleId: 'platform-configs', + sourceRelativePath: '.opencode/opencode.json', + }), true); + assert.strictEqual(isHookRuntimeOperation({ + kind: 'copy-file', + moduleId: 'platform-configs', + sourceRelativePath: '.opencode/opencode.json', + contentTransform: 'opencode-disable-ecc-hooks', + }), false); + assert.strictEqual(isHookRuntimeOperation({ + kind: 'merge-json', + moduleId: 'platform-configs', + sourceRelativePath: '.opencode/opencode.json', + contentTransform: 'opencode-disable-ecc-hooks', + }), true); assert.strictEqual(isHookRuntimeOperation({ sourceRelativePath: 'rules/common.md' }), false); assert.strictEqual( isHookRuntimeOperation({ sourceRelativePath: 'skills/webhooks-guide.md' }), @@ -96,6 +114,23 @@ function runTests() { assert.strictEqual(planMaterializesHookRuntime({}), false); })) passed++; else failed++; + if (test('removes only ECC hook activation from OpenCode config', () => { + const transformed = disableOpenCodeHookPluginRegistration(JSON.stringify({ + plugin: ['./plugins', 'example-plugin'], + instructions: ['AGENTS.md'], + }), '.opencode/opencode.json'); + assert.deepStrictEqual(JSON.parse(transformed), { + plugin: ['example-plugin'], + instructions: ['AGENTS.md'], + }); + assert.deepStrictEqual(JSON.parse(disableOpenCodeHookPluginRegistration( + JSON.stringify({ instructions: ['AGENTS.md'] }), + '.opencode/opencode.json' + )), { + instructions: ['AGENTS.md'], + }); + })) passed++; else failed++; + if (test('formats one numbered disclosure line per capability group', () => { const disclosure = formatHookCapabilityDisclosure(); const lines = disclosure.split('\n'); diff --git a/tests/lib/install-executor.test.js b/tests/lib/install-executor.test.js index 4a65ce5ef..e9f43d5b3 100644 --- a/tests/lib/install-executor.test.js +++ b/tests/lib/install-executor.test.js @@ -19,6 +19,8 @@ const { listAvailableLanguages, } = require('../../scripts/lib/install-executor'); const { applyInstallPlan: applyInstallPlanDirect } = require('../../scripts/lib/install/apply'); +const { normalizeInstallRequest } = require('../../scripts/lib/install/request'); +const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime'); const REPO_ROOT = path.resolve(__dirname, '..', '..'); @@ -640,6 +642,106 @@ function runTests() { } })) passed++; else failed++; + if (test('OpenCode profile keeps plugin source dormant until hook opt-in is consented', () => { + const homeDir = createTempDir('install-executor-opencode-boundary-'); + try { + const planOptions = { + sourceRoot: REPO_ROOT, + homeDir, + projectRoot: homeDir, + exemptValidationCodes: ['opencode-plugin-not-built'], + }; + const rawDefaultPlan = createManifestInstallPlan({ + ...planOptions, + target: 'opencode', + profileId: 'opencode', + }); + assert.strictEqual( + rawDefaultPlan.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )).contentTransform, + 'opencode-disable-ecc-hooks' + ); + const defaultPlan = createInstallPlanFromRequest( + normalizeInstallRequest({ target: 'opencode', profileId: 'opencode' }), + planOptions + ); + const defaultConfig = defaultPlan.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )); + const defaultStateConfig = defaultPlan.statePreview.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )); + + assert.strictEqual(defaultConfig.contentTransform, 'opencode-disable-ecc-hooks'); + assert.strictEqual(defaultStateConfig.contentTransform, 'opencode-disable-ecc-hooks'); + assert.ok(defaultPlan.operations.some(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/plugins/ecc-hooks.ts' + )), 'Default plan should still copy dormant plugin source'); + + applyInstallPlanDirect(defaultPlan, { writeInstallState() {} }); + const installedConfig = JSON.parse(fs.readFileSync( + path.join(homeDir, '.config', 'opencode', 'opencode.json'), + 'utf8' + )); + assert.ok(!installedConfig.plugin.includes('./plugins')); + + const enabledWithoutRuntime = createInstallPlanFromRequest( + normalizeInstallRequest({ + target: 'opencode', + profileId: 'opencode', + enableHooks: true, + }), + planOptions + ); + assert.strictEqual( + enabledWithoutRuntime.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )).contentTransform, + 'opencode-disable-ecc-hooks' + ); + + const declinedPlan = createInstallPlanFromRequest( + normalizeInstallRequest({ target: 'opencode', profileId: 'core', noHooks: true }), + planOptions + ); + assert.strictEqual( + declinedPlan.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )).contentTransform, + 'opencode-disable-ecc-hooks' + ); + assert.ok(!declinedPlan.operations.some(operation => operation.moduleId === 'hooks-runtime')); + + const pendingPlan = createInstallPlanFromRequest( + normalizeInstallRequest({ + target: 'opencode', + moduleIds: ['platform-configs', 'hooks-runtime'], + }), + planOptions + ); + assert.throws( + () => applyInstallPlanDirect(pendingPlan, { writeInstallState() {} }), + /automatic hook runtime/ + ); + + const enabledPlan = createInstallPlanFromRequest( + normalizeInstallRequest({ + target: 'opencode', + moduleIds: ['platform-configs', 'hooks-runtime'], + enableHooks: true, + }), + planOptions + ); + const enabledConfig = enabledPlan.operations.find(operation => ( + operation.sourceRelativePath.split(path.sep).join('/') === '.opencode/opencode.json' + )); + assert.strictEqual(enabledConfig.contentTransform, undefined); + } finally { + cleanup(homeDir); + } + })) passed++; else failed++; + if (test('Claude hooks install refuses a symlinked hooks destination', () => { if (process.platform === 'win32') return; diff --git a/tests/lib/install-lifecycle.test.js b/tests/lib/install-lifecycle.test.js index 51d39f9e1..4f362d158 100644 --- a/tests/lib/install-lifecycle.test.js +++ b/tests/lib/install-lifecycle.test.js @@ -1869,6 +1869,55 @@ function runTests() { } })) passed++; else failed++; + if (test('doctor dispatches the OpenCode hook-disable content transform consistently', () => { + const projectRoot = createTempDir('install-lifecycle-opencode-transform-'); + + try { + const targetRoot = path.join(projectRoot, '.cursor'); + const installStatePath = path.join(targetRoot, 'ecc-install-state.json'); + const destinationPath = path.join(targetRoot, 'opencode.json'); + const sourceConfig = JSON.parse(fs.readFileSync( + path.join(REPO_ROOT, '.opencode', 'opencode.json'), + 'utf8' + )); + const expectedContent = `${JSON.stringify({ + ...sourceConfig, + plugin: sourceConfig.plugin.filter(plugin => plugin !== './plugins'), + }, null, 2)}\n`; + fs.mkdirSync(targetRoot, { recursive: true }); + fs.writeFileSync(destinationPath, expectedContent, 'utf8'); + writeState(installStatePath, createCursorStateOptions(projectRoot, { + targetRoot, + installStatePath, + operations: [{ + kind: 'copy-file', + moduleId: 'platform-configs', + sourceRelativePath: '.opencode/opencode.json', + destinationPath, + strategy: 'preserve-relative-path', + ownership: 'managed', + scaffoldOnly: false, + contentTransform: 'opencode-disable-ecc-hooks', + }], + })); + + const report = buildDoctorReport({ + repoRoot: REPO_ROOT, + homeDir: projectRoot, + projectRoot, + targets: ['cursor'], + }); + + assert.strictEqual(report.results.length, 1); + assert.ok(!report.results[0].issues.some(issue => ( + issue.code === 'drifted-managed-files' + || issue.code === 'unverified-managed-operations' + ))); + } finally { + 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-');