@@ -29,7 +29,7 @@ vi.mock('../hooks', () => ({
2929
3030import { homedir } from 'os'
3131import { join } from 'path'
32- import { buildSpawnArgs , hooksInstalled , installHooks , hookEvents , uninstallHooks } from './claude'
32+ import { buildSpawnArgs , hookEvents , stripGlobalHooks } from './claude'
3333
3434const SETTINGS_PATH = join ( homedir ( ) , '.claude' , 'settings.json' )
3535
@@ -62,63 +62,42 @@ describe('buildSpawnArgs', () => {
6262 expect ( result ) . toContain ( '--append-system-prompt' )
6363 expect ( result ) . toContain ( "'\\''" )
6464 } )
65- } )
6665
67- describe ( 'hook install / dedup' , ( ) => {
68- it ( 'hooksInstalled() recognizes normalized entries with no _marker field' , ( ) => {
69- // Simulate what Claude Code leaves behind after normalizing settings.json:
70- // the _marker and _version sidecar fields are stripped, only the
71- // {type, command, timeout} triple remains.
72- const settings = {
73- hooks : {
74- UserPromptSubmit : [
75- {
76- hooks : [
77- {
78- type : 'command' ,
79- command :
80- "bash -c 'd=/tmp/harness-status; printf hi >> \"$d/$h.ndjson\"'" ,
81- timeout : 5
82- }
83- ]
84- }
85- ]
86- }
87- }
88- fsState . files . set ( SETTINGS_PATH , JSON . stringify ( settings ) )
89- expect ( hooksInstalled ( ) ) . toBe ( true )
66+ it ( 'passes --plugin-dir pointing at the bundled Harness status plugin' , ( ) => {
67+ const result = buildSpawnArgs ( { ...base } )
68+ expect ( result ) . toContain ( '--plugin-dir' )
69+ expect ( result ) . toContain ( 'resources/plugins/harness-status' )
9070 } )
71+ } )
9172
92- it ( 'hooksInstalled() returns false when only user-authored hooks exist' , ( ) => {
93- const settings = {
94- hooks : {
95- UserPromptSubmit : [
96- {
97- hooks : [ { type : 'command' , command : 'echo user hook' , timeout : 5 } ]
98- }
99- ]
100- }
101- }
102- fsState . files . set ( SETTINGS_PATH , JSON . stringify ( settings ) )
103- expect ( hooksInstalled ( ) ) . toBe ( false )
73+ describe ( 'stripGlobalHooks (legacy migration)' , ( ) => {
74+ it ( 'returns false when settings.json has no Harness entries' , ( ) => {
75+ fsState . files . set (
76+ SETTINGS_PATH ,
77+ JSON . stringify ( {
78+ hooks : {
79+ UserPromptSubmit : [
80+ {
81+ hooks : [ { type : 'command' , command : 'echo user hook' , timeout : 5 } ]
82+ }
83+ ]
84+ }
85+ } )
86+ )
87+ expect ( stripGlobalHooks ( ) ) . toBe ( false )
88+ const after = JSON . parse ( fsState . files . get ( SETTINGS_PATH ) as string )
89+ expect ( after . hooks . UserPromptSubmit ) . toHaveLength ( 1 )
10490 } )
10591
106- it ( 'installHooks() called twice yields exactly one harness entry per event' , ( ) => {
107- installHooks ( )
108- installHooks ( )
109- const settings = JSON . parse ( fsState . files . get ( SETTINGS_PATH ) as string )
110- for ( const event of hookEvents ) {
111- const entries = settings . hooks [ event ]
112- expect ( entries ) . toHaveLength ( 1 )
113- expect ( entries [ 0 ] . hooks [ 0 ] . command ) . toContain ( '/tmp/harness-status' )
114- }
92+ it ( 'returns false when settings.json does not exist' , ( ) => {
93+ expect ( stripGlobalHooks ( ) ) . toBe ( false )
11594 } )
11695
117- it ( 'installHooks() collapses pre-existing duplicates left by buggy passes ' , ( ) => {
118- // Three duplicate harness entries per event, all in normalized form
119- // (no _marker / _version). This is the exact shape the user reports
120- // after several buggy install passes.
121- const dupEntry = {
96+ it ( 'removes legacy Harness entries while preserving user-authored hooks ' , ( ) => {
97+ const userHook = {
98+ hooks : [ { type : 'command' , command : 'echo user hook' , timeout : 10 } ]
99+ }
100+ const harnessHook = {
122101 hooks : [
123102 {
124103 type : 'command' ,
@@ -128,66 +107,62 @@ describe('hook install / dedup', () => {
128107 }
129108 ]
130109 }
131- const settings : { hooks : Record < string , unknown [ ] > } = { hooks : { } }
132- for ( const event of hookEvents ) {
133- settings . hooks [ event ] = [ dupEntry , dupEntry , dupEntry ]
134- }
135- fsState . files . set ( SETTINGS_PATH , JSON . stringify ( settings ) )
136-
137- installHooks ( )
138-
139- const after = JSON . parse ( fsState . files . get ( SETTINGS_PATH ) as string )
140- for ( const event of hookEvents ) {
141- expect ( after . hooks [ event ] ) . toHaveLength ( 1 )
142- }
143- } )
144-
145- it ( 'installHooks() preserves user-authored hooks (commands not pointing at /tmp/harness-status)' , ( ) => {
146- const userHook = {
147- hooks : [ { type : 'command' , command : 'echo user hook' , timeout : 10 } ]
148- }
149110 fsState . files . set (
150111 SETTINGS_PATH ,
151112 JSON . stringify ( {
152113 hooks : {
153- UserPromptSubmit : [ userHook ] ,
154- PreToolUse : [ userHook ]
114+ UserPromptSubmit : [ userHook , harnessHook ] ,
115+ PreToolUse : [ harnessHook ]
155116 } ,
156117 unrelatedKey : 'preserve-me'
157118 } )
158119 )
159120
160- installHooks ( )
161-
121+ expect ( stripGlobalHooks ( ) ) . toBe ( true )
162122 const after = JSON . parse ( fsState . files . get ( SETTINGS_PATH ) as string )
163123 expect ( after . unrelatedKey ) . toBe ( 'preserve-me' )
164- // User hook still there + one harness entry appended
165- expect ( after . hooks . UserPromptSubmit ) . toContainEqual ( userHook )
166- expect ( after . hooks . PreToolUse ) . toContainEqual ( userHook )
167- for ( const event of hookEvents ) {
168- const harnessEntries = ( after . hooks [ event ] as Array < { hooks : { command : string } [ ] } > ) . filter (
169- ( e ) => e . hooks . some ( ( h ) => h . command . includes ( '/tmp/harness-status' ) )
170- )
171- expect ( harnessEntries ) . toHaveLength ( 1 )
172- }
124+ // User hook survives; harness entry stripped.
125+ expect ( after . hooks . UserPromptSubmit ) . toEqual ( [ userHook ] )
126+ // Event with only harness entry → key removed entirely.
127+ expect ( after . hooks . PreToolUse ) . toBeUndefined ( )
173128 } )
174129
175- it ( 'uninstallHooks() removes harness entries but preserves user-authored hooks' , ( ) => {
176- installHooks ( )
177- // Add a user-authored hook alongside
178- const after = JSON . parse ( fsState . files . get ( SETTINGS_PATH ) as string )
179- after . hooks . UserPromptSubmit . push ( {
180- hooks : [ { type : 'command' , command : 'echo user hook' } ]
181- } )
182- fsState . files . set ( SETTINGS_PATH , JSON . stringify ( after ) )
130+ it ( 'drops the hooks object entirely when no events remain' , ( ) => {
131+ fsState . files . set (
132+ SETTINGS_PATH ,
133+ JSON . stringify ( {
134+ hooks : {
135+ PreToolUse : [
136+ {
137+ hooks : [
138+ {
139+ type : 'command' ,
140+ command : 'bash -c \'d=/tmp/harness-status; echo x\'' ,
141+ timeout : 5
142+ }
143+ ]
144+ }
145+ ]
146+ } ,
147+ otherKey : 'keep'
148+ } )
149+ )
183150
184- uninstallHooks ( )
151+ expect ( stripGlobalHooks ( ) ) . toBe ( true )
152+ const after = JSON . parse ( fsState . files . get ( SETTINGS_PATH ) as string )
153+ expect ( after . hooks ) . toBeUndefined ( )
154+ expect ( after . otherKey ) . toBe ( 'keep' )
155+ } )
156+ } )
185157
186- const final = JSON . parse ( fsState . files . get ( SETTINGS_PATH ) as string )
187- expect ( final . hooks ?. UserPromptSubmit ) . toEqual ( [
188- { hooks : [ { type : 'command' , command : 'echo user hook' } ] }
158+ describe ( 'hookEvents' , ( ) => {
159+ it ( 'exports the events the bundled plugin must register' , ( ) => {
160+ expect ( hookEvents ) . toEqual ( [
161+ 'UserPromptSubmit' ,
162+ 'PreToolUse' ,
163+ 'PostToolUse' ,
164+ 'Stop' ,
165+ 'Notification'
189166 ] )
190- // Other events had no user hooks, so they should be gone entirely.
191- expect ( final . hooks ?. PreToolUse ) . toBeUndefined ( )
192167 } )
193168} )
0 commit comments