@@ -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+
2527function 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