Skip to content

Commit a3fc90f

Browse files
authored
Merge pull request #964 from affaan-m/fix/claude-hooks-settings-merge-safe
fix(installer): preserve existing Claude hook settings
2 parents 55efeb7 + 47aa415 commit a3fc90f

File tree

4 files changed

+261
-1
lines changed

4 files changed

+261
-1
lines changed

scripts/ecc.js

100644100755
File mode changed.

scripts/install-apply.js

100644100755
File mode changed.

scripts/lib/install/apply.js

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,109 @@
11
'use strict';
22

33
const fs = require('fs');
4+
const path = require('path');
45

56
const { writeInstallState } = require('../install-state');
67

8+
function readJsonObject(filePath, label) {
9+
let parsed;
10+
try {
11+
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
12+
} catch (error) {
13+
throw new Error(`Failed to parse ${label} at ${filePath}: ${error.message}`);
14+
}
15+
16+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
17+
throw new Error(`Invalid ${label} at ${filePath}: expected a JSON object`);
18+
}
19+
20+
return parsed;
21+
}
22+
23+
function mergeHookEntries(existingEntries, incomingEntries) {
24+
const mergedEntries = [];
25+
const seenEntries = new Set();
26+
27+
for (const entry of [...existingEntries, ...incomingEntries]) {
28+
const entryKey = JSON.stringify(entry);
29+
if (seenEntries.has(entryKey)) {
30+
continue;
31+
}
32+
33+
seenEntries.add(entryKey);
34+
mergedEntries.push(entry);
35+
}
36+
37+
return mergedEntries;
38+
}
39+
40+
function findHooksSourcePath(plan, hooksDestinationPath) {
41+
const operation = plan.operations.find(item => item.destinationPath === hooksDestinationPath);
42+
return operation ? operation.sourcePath : null;
43+
}
44+
45+
function buildMergedSettings(plan) {
46+
if (!plan.adapter || plan.adapter.target !== 'claude') {
47+
return null;
48+
}
49+
50+
const hooksDestinationPath = path.join(plan.targetRoot, 'hooks', 'hooks.json');
51+
const hooksSourcePath = findHooksSourcePath(plan, hooksDestinationPath) || hooksDestinationPath;
52+
if (!fs.existsSync(hooksSourcePath)) {
53+
return null;
54+
}
55+
56+
const hooksConfig = readJsonObject(hooksSourcePath, 'hooks config');
57+
const incomingHooks = hooksConfig.hooks;
58+
if (!incomingHooks || typeof incomingHooks !== 'object' || Array.isArray(incomingHooks)) {
59+
throw new Error(`Invalid hooks config at ${hooksSourcePath}: expected "hooks" to be a JSON object`);
60+
}
61+
62+
const settingsPath = path.join(plan.targetRoot, 'settings.json');
63+
let settings = {};
64+
if (fs.existsSync(settingsPath)) {
65+
settings = readJsonObject(settingsPath, 'existing settings');
66+
}
67+
68+
const existingHooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
69+
? settings.hooks
70+
: {};
71+
const mergedHooks = { ...existingHooks };
72+
73+
for (const [eventName, incomingEntries] of Object.entries(incomingHooks)) {
74+
const currentEntries = Array.isArray(existingHooks[eventName]) ? existingHooks[eventName] : [];
75+
const nextEntries = Array.isArray(incomingEntries) ? incomingEntries : [];
76+
mergedHooks[eventName] = mergeHookEntries(currentEntries, nextEntries);
77+
}
78+
79+
const mergedSettings = {
80+
...settings,
81+
hooks: mergedHooks,
82+
};
83+
84+
return {
85+
settingsPath,
86+
mergedSettings,
87+
};
88+
}
89+
790
function applyInstallPlan(plan) {
91+
const mergedSettingsPlan = buildMergedSettings(plan);
92+
893
for (const operation of plan.operations) {
9-
fs.mkdirSync(require('path').dirname(operation.destinationPath), { recursive: true });
94+
fs.mkdirSync(path.dirname(operation.destinationPath), { recursive: true });
1095
fs.copyFileSync(operation.sourcePath, operation.destinationPath);
1196
}
1297

98+
if (mergedSettingsPlan) {
99+
fs.mkdirSync(path.dirname(mergedSettingsPlan.settingsPath), { recursive: true });
100+
fs.writeFileSync(
101+
mergedSettingsPlan.settingsPath,
102+
JSON.stringify(mergedSettingsPlan.mergedSettings, null, 2) + '\n',
103+
'utf8'
104+
);
105+
}
106+
13107
writeInstallState(plan.installStatePath, plan.statePreview);
14108

15109
return {

tests/scripts/install-apply.test.js

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ function readJson(filePath) {
2222
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
2323
}
2424

25+
const REPO_ROOT = path.join(__dirname, '..', '..');
26+
2527
function run(args = [], options = {}) {
2628
const env = {
2729
...process.env,
@@ -326,6 +328,170 @@ function runTests() {
326328
assert.ok(result.stderr.includes('Unknown install module: ghost-module'));
327329
})) passed++; else failed++;
328330

331+
if (test('merges hooks into settings.json for claude target install', () => {
332+
const homeDir = createTempDir('install-apply-home-');
333+
const projectDir = createTempDir('install-apply-project-');
334+
335+
try {
336+
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
337+
assert.strictEqual(result.code, 0, result.stderr);
338+
339+
const claudeRoot = path.join(homeDir, '.claude');
340+
assert.ok(fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should be copied');
341+
342+
const settingsPath = path.join(claudeRoot, 'settings.json');
343+
assert.ok(fs.existsSync(settingsPath), 'settings.json should exist after install');
344+
345+
const settings = readJson(settingsPath);
346+
assert.ok(settings.hooks, 'settings.json should contain hooks key');
347+
assert.ok(settings.hooks.PreToolUse, 'hooks should include PreToolUse');
348+
assert.ok(Array.isArray(settings.hooks.PreToolUse), 'PreToolUse should be an array');
349+
assert.ok(settings.hooks.PreToolUse.length > 0, 'PreToolUse should have entries');
350+
} finally {
351+
cleanup(homeDir);
352+
cleanup(projectDir);
353+
}
354+
})) passed++; else failed++;
355+
356+
if (test('preserves existing settings fields and hook entries when merging hooks', () => {
357+
const homeDir = createTempDir('install-apply-home-');
358+
const projectDir = createTempDir('install-apply-project-');
359+
360+
try {
361+
const claudeRoot = path.join(homeDir, '.claude');
362+
fs.mkdirSync(claudeRoot, { recursive: true });
363+
fs.writeFileSync(
364+
path.join(claudeRoot, 'settings.json'),
365+
JSON.stringify({
366+
effortLevel: 'high',
367+
env: { MY_VAR: '1' },
368+
hooks: {
369+
PreToolUse: [{ matcher: 'Write', hooks: [{ type: 'command', command: 'echo custom-pretool' }] }],
370+
UserPromptSubmit: [{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }],
371+
},
372+
}, null, 2)
373+
);
374+
375+
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
376+
assert.strictEqual(result.code, 0, result.stderr);
377+
378+
const settings = readJson(path.join(claudeRoot, 'settings.json'));
379+
assert.strictEqual(settings.effortLevel, 'high', 'existing effortLevel should be preserved');
380+
assert.deepStrictEqual(settings.env, { MY_VAR: '1' }, 'existing env should be preserved');
381+
assert.ok(settings.hooks, 'hooks should be merged in');
382+
assert.ok(settings.hooks.PreToolUse, 'PreToolUse hooks should exist');
383+
assert.ok(
384+
settings.hooks.PreToolUse.some(entry => JSON.stringify(entry).includes('echo custom-pretool')),
385+
'existing PreToolUse entries should be preserved'
386+
);
387+
assert.ok(settings.hooks.PreToolUse.length > 1, 'ECC PreToolUse hooks should be appended');
388+
assert.deepStrictEqual(
389+
settings.hooks.UserPromptSubmit,
390+
[{ matcher: '*', hooks: [{ type: 'command', command: 'echo custom-submit' }] }],
391+
'user-defined hook event types should be preserved'
392+
);
393+
} finally {
394+
cleanup(homeDir);
395+
cleanup(projectDir);
396+
}
397+
})) passed++; else failed++;
398+
399+
if (test('reinstall does not duplicate managed hook entries', () => {
400+
const homeDir = createTempDir('install-apply-home-');
401+
const projectDir = createTempDir('install-apply-project-');
402+
403+
try {
404+
const firstInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
405+
assert.strictEqual(firstInstall.code, 0, firstInstall.stderr);
406+
407+
const settingsPath = path.join(homeDir, '.claude', 'settings.json');
408+
const afterFirstInstall = readJson(settingsPath);
409+
const preToolUseLength = afterFirstInstall.hooks.PreToolUse.length;
410+
411+
const secondInstall = run(['--profile', 'core'], { cwd: projectDir, homeDir });
412+
assert.strictEqual(secondInstall.code, 0, secondInstall.stderr);
413+
414+
const afterSecondInstall = readJson(settingsPath);
415+
assert.strictEqual(
416+
afterSecondInstall.hooks.PreToolUse.length,
417+
preToolUseLength,
418+
'managed hook entries should not duplicate on reinstall'
419+
);
420+
} finally {
421+
cleanup(homeDir);
422+
cleanup(projectDir);
423+
}
424+
})) passed++; else failed++;
425+
426+
if (test('fails when existing settings.json is malformed', () => {
427+
const homeDir = createTempDir('install-apply-home-');
428+
const projectDir = createTempDir('install-apply-project-');
429+
430+
try {
431+
const claudeRoot = path.join(homeDir, '.claude');
432+
fs.mkdirSync(claudeRoot, { recursive: true });
433+
const settingsPath = path.join(claudeRoot, 'settings.json');
434+
fs.writeFileSync(settingsPath, '{ invalid json\n');
435+
436+
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
437+
assert.strictEqual(result.code, 1);
438+
assert.ok(result.stderr.includes('Failed to parse existing settings at'));
439+
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '{ invalid json\n');
440+
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied on validation failure');
441+
assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written on validation failure');
442+
} finally {
443+
cleanup(homeDir);
444+
cleanup(projectDir);
445+
}
446+
})) passed++; else failed++;
447+
448+
if (test('fails when existing settings.json root is not an object', () => {
449+
const homeDir = createTempDir('install-apply-home-');
450+
const projectDir = createTempDir('install-apply-project-');
451+
452+
try {
453+
const claudeRoot = path.join(homeDir, '.claude');
454+
fs.mkdirSync(claudeRoot, { recursive: true });
455+
const settingsPath = path.join(claudeRoot, 'settings.json');
456+
fs.writeFileSync(settingsPath, '[]\n');
457+
458+
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
459+
assert.strictEqual(result.code, 1);
460+
assert.ok(result.stderr.includes('Invalid existing settings at'));
461+
assert.ok(result.stderr.includes('expected a JSON object'));
462+
assert.strictEqual(fs.readFileSync(settingsPath, 'utf8'), '[]\n');
463+
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied on validation failure');
464+
assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written on validation failure');
465+
} finally {
466+
cleanup(homeDir);
467+
cleanup(projectDir);
468+
}
469+
})) passed++; else failed++;
470+
471+
if (test('fails when source hooks.json root is not an object before copying files', () => {
472+
const homeDir = createTempDir('install-apply-home-');
473+
const projectDir = createTempDir('install-apply-project-');
474+
const sourceHooksPath = path.join(REPO_ROOT, 'hooks', 'hooks.json');
475+
const originalHooks = fs.readFileSync(sourceHooksPath, 'utf8');
476+
477+
try {
478+
fs.writeFileSync(sourceHooksPath, '[]\n');
479+
480+
const result = run(['--profile', 'core'], { cwd: projectDir, homeDir });
481+
assert.strictEqual(result.code, 1);
482+
assert.ok(result.stderr.includes('Invalid hooks config at'));
483+
assert.ok(result.stderr.includes('expected a JSON object'));
484+
485+
const claudeRoot = path.join(homeDir, '.claude');
486+
assert.ok(!fs.existsSync(path.join(claudeRoot, 'hooks', 'hooks.json')), 'hooks.json should not be copied when source hooks are invalid');
487+
assert.ok(!fs.existsSync(path.join(claudeRoot, 'ecc', 'install-state.json')), 'install state should not be written when source hooks are invalid');
488+
} finally {
489+
fs.writeFileSync(sourceHooksPath, originalHooks);
490+
cleanup(homeDir);
491+
cleanup(projectDir);
492+
}
493+
})) passed++; else failed++;
494+
329495
if (test('installs from ecc-install.json and persists component selections', () => {
330496
const homeDir = createTempDir('install-apply-home-');
331497
const projectDir = createTempDir('install-apply-project-');

0 commit comments

Comments
 (0)