fix(installer): preserve existing claude hook settings

This commit is contained in:
Affaan Mustafa
2026-03-27 07:52:03 -04:00
parent f07797533d
commit 9f37a5d8c7
2 changed files with 187 additions and 1 deletions

View File

@@ -1,15 +1,86 @@
'use strict'; 'use strict';
const fs = require('fs'); const fs = require('fs');
const path = require('path');
const { writeInstallState } = require('../install-state'); 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) { function applyInstallPlan(plan) {
for (const operation of plan.operations) { 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); fs.copyFileSync(operation.sourcePath, operation.destinationPath);
} }
mergeHooksIntoSettings(plan);
writeInstallState(plan.installStatePath, plan.statePreview); writeInstallState(plan.installStatePath, plan.statePreview);
return { return {

View File

@@ -326,6 +326,121 @@ function runTests() {
assert.ok(result.stderr.includes('Unknown install module: ghost-module')); assert.ok(result.stderr.includes('Unknown install module: ghost-module'));
})) passed++; else failed++; })) 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', () => { if (test('installs from ecc-install.json and persists component selections', () => {
const homeDir = createTempDir('install-apply-home-'); const homeDir = createTempDir('install-apply-home-');
const projectDir = createTempDir('install-apply-project-'); const projectDir = createTempDir('install-apply-project-');