From 9f37a5d8c76f42e19a75acd987e2c19cd7660a9e Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Fri, 27 Mar 2026 07:52:03 -0400 Subject: [PATCH] fix(installer): preserve existing claude hook settings --- scripts/lib/install/apply.js | 73 +++++++++++++++++- tests/scripts/install-apply.test.js | 115 ++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 1 deletion(-) diff --git a/scripts/lib/install/apply.js b/scripts/lib/install/apply.js index 567d4a78..26fce135 100644 --- a/scripts/lib/install/apply.js +++ b/scripts/lib/install/apply.js @@ -1,15 +1,86 @@ 'use strict'; const fs = require('fs'); +const path = require('path'); const { writeInstallState } = require('../install-state'); +function mergeHookEntries(existingEntries, incomingEntries) { + const mergedEntries = []; + const seenEntries = new Set(); + + for (const entry of [...existingEntries, ...incomingEntries]) { + const entryKey = JSON.stringify(entry); + if (seenEntries.has(entryKey)) { + continue; + } + + seenEntries.add(entryKey); + mergedEntries.push(entry); + } + + return mergedEntries; +} + +function mergeHooksIntoSettings(plan) { + if (!plan.adapter || plan.adapter.target !== 'claude') { + return; + } + + const hooksJsonPath = path.join(plan.targetRoot, 'hooks', 'hooks.json'); + if (!fs.existsSync(hooksJsonPath)) { + return; + } + + let hooksConfig; + try { + hooksConfig = JSON.parse(fs.readFileSync(hooksJsonPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse hooks config at ${hooksJsonPath}: ${error.message}`); + } + + const incomingHooks = hooksConfig.hooks; + if (!incomingHooks || typeof incomingHooks !== 'object' || Array.isArray(incomingHooks)) { + return; + } + + const settingsPath = path.join(plan.targetRoot, 'settings.json'); + let settings = {}; + if (fs.existsSync(settingsPath)) { + try { + settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to parse existing settings at ${settingsPath}: ${error.message}`); + } + } + + const existingHooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks) + ? settings.hooks + : {}; + const mergedHooks = { ...existingHooks }; + + for (const [eventName, incomingEntries] of Object.entries(incomingHooks)) { + const currentEntries = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : []; + const nextEntries = Array.isArray(incomingEntries) ? incomingEntries : []; + mergedHooks[eventName] = mergeHookEntries(currentEntries, nextEntries); + } + + const mergedSettings = { + ...settings, + hooks: mergedHooks, + }; + + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify(mergedSettings, null, 2) + '\n', 'utf8'); +} + function applyInstallPlan(plan) { for (const operation of plan.operations) { - fs.mkdirSync(require('path').dirname(operation.destinationPath), { recursive: true }); + fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true }); fs.copyFileSync(operation.sourcePath, operation.destinationPath); } + mergeHooksIntoSettings(plan); writeInstallState(plan.installStatePath, plan.statePreview); return { diff --git a/tests/scripts/install-apply.test.js b/tests/scripts/install-apply.test.js index 39743bfc..13b9d7fa 100644 --- a/tests/scripts/install-apply.test.js +++ b/tests/scripts/install-apply.test.js @@ -326,6 +326,121 @@ function runTests() { assert.ok(result.stderr.includes('Unknown install module: ghost-module')); })) passed++; else failed++; + if (test('merges hooks into settings.json for claude target install', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + + const claudeRoot = path.join(homeDir, '.claude'); + assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied'); + + const settingsPath = path.join(claudeRoot, 'settings.json'); + assert.ok(fs.existsSync(settingsPath), 'settings.json should exist after install'); + + const settings = readJson(settingsPath); + assert.ok(settings.hooks, 'settings.json should contain hooks key'); + assert.ok(settings.hooks.PreToolUse, 'hooks should include PreToolUse'); + assert.ok(Array.isArray(settings.hooks.PreToolUse), 'PreToolUse should be an array'); + assert.ok(settings.hooks.PreToolUse.length > 0, 'PreToolUse should have entries'); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('preserves existing settings fields and hook entries when merging hooks', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const claudeRoot = path.join(homeDir, '.claude'); + fs.mkdirSync(claudeRoot, { recursive: true }); + fs.writeFileSync( + path.join(claudeRoot, 'settings.json'), + JSON.stringify({ + effortLevel: 'high', + env: { MY_VAR: '1' }, + hooks: { + PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }], + UserPromptSubmit: [{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }], + }, + }, null, 2) + ); + + const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 0, result.stderr); + + const settings = readJson(path.join(claudeRoot, 'settings.json')); + assert.strictEqual(settings.effortLevel, 'high', 'existing effortLevel should be preserved'); + assert.deepStrictEqual(settings.env, { MY_VAR: '1' }, 'existing env should be preserved'); + assert.ok(settings.hooks, 'hooks should be merged in'); + assert.ok(settings.hooks.PreToolUse, 'PreToolUse hooks should exist'); + assert.ok( + settings.hooks.PreToolUse.some(entry => JSON.stringify(entry).includes('echo custom-pretool')), + 'existing PreToolUse entries should be preserved' + ); + assert.ok(settings.hooks.PreToolUse.length > 1, 'ECC PreToolUse hooks should be appended'); + assert.deepStrictEqual( + settings.hooks.UserPromptSubmit, + [{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }], + 'user-defined hook event types should be preserved' + ); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('reinstall does not duplicate managed hook entries', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(firstInstall.code, 0, firstInstall.stderr); + + const settingsPath = path.join(homeDir, '.claude', 'settings.json'); + const afterFirstInstall = readJson(settingsPath); + const preToolUseLength = afterFirstInstall.hooks.PreToolUse.length; + + const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(secondInstall.code, 0, secondInstall.stderr); + + const afterSecondInstall = readJson(settingsPath); + assert.strictEqual( + afterSecondInstall.hooks.PreToolUse.length, + preToolUseLength, + 'managed hook entries should not duplicate on reinstall' + ); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + + if (test('fails when existing settings.json is malformed', () => { + const homeDir = createTempDir('install-apply-home-'); + const projectDir = createTempDir('install-apply-project-'); + + try { + const claudeRoot = path.join(homeDir, '.claude'); + fs.mkdirSync(claudeRoot, { recursive: true }); + const settingsPath = path.join(claudeRoot, 'settings.json'); + fs.writeFileSync(settingsPath, '{ invalid json\n'); + + const result = run(['--profile', 'core'], { cwd: projectDir, homeDir }); + assert.strictEqual(result.code, 1); + assert.ok(result.stderr.includes('Failed to parse existing settings at')); + assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n'); + } finally { + cleanup(homeDir); + cleanup(projectDir); + } + })) passed++; else failed++; + if (test('installs from ecc-install.json and persists component selections', () => { const homeDir = createTempDir('install-apply-home-'); const projectDir = createTempDir('install-apply-project-');