1010use serde_json:: { Map , Value } ;
1111use thiserror:: Error ;
1212
13+ // Claude Code's `.claude/settings.json` hook schema. These keys appear
14+ // across the merge / unmerge / lookup helpers; pinning them as constants
15+ // turns a typo into a compile error instead of a silent no-op.
16+ const HOOKS : & str = "hooks" ;
17+ const PRETOOL_USE : & str = "PreToolUse" ;
18+ const MATCHER : & str = "matcher" ;
19+ const TYPE : & str = "type" ;
20+ const COMMAND : & str = "command" ;
21+ const BASH : & str = "Bash" ;
22+
1323#[ derive( Debug , Error ) ]
1424pub enum SettingsError {
1525 #[ error( "settings.json: invalid JSON: {0}" ) ]
@@ -43,42 +53,52 @@ pub fn merge_hook_entry(settings_json: &str, hook_command: &str) -> Result<Strin
4353
4454 let root_obj = expect_object_mut ( & mut root, "" ) ?;
4555
46- let hooks = get_or_insert_object ( root_obj, "hooks" ) ?;
47- let pretool = get_or_insert_array ( hooks, "PreToolUse" , "hooks.PreToolUse" ) ?;
56+ let hooks = get_or_insert_object ( root_obj, HOOKS , HOOKS ) ?;
57+ let pretool = get_or_insert_array ( hooks, PRETOOL_USE , "hooks.PreToolUse" ) ?;
4858
4959 let bash_idx = find_bash_matcher ( pretool) ?;
5060 let bash_entry = match bash_idx {
5161 Some ( i) => & mut pretool[ i] ,
5262 None => {
5363 pretool. push ( Value :: Object ( {
5464 let mut m = Map :: new ( ) ;
55- m. insert ( "matcher" . into ( ) , Value :: String ( "Bash" . into ( ) ) ) ;
56- m. insert ( "hooks" . into ( ) , Value :: Array ( Vec :: new ( ) ) ) ;
65+ m. insert ( MATCHER . into ( ) , Value :: String ( BASH . into ( ) ) ) ;
66+ m. insert ( HOOKS . into ( ) , Value :: Array ( Vec :: new ( ) ) ) ;
5767 m
5868 } ) ) ;
5969 pretool. last_mut ( ) . expect ( "just pushed" )
6070 }
6171 } ;
6272
6373 let bash_obj = expect_object_mut ( bash_entry, "hooks.PreToolUse[Bash]" ) ?;
64- let inner = get_or_insert_array ( bash_obj, "hooks" , "hooks.PreToolUse[Bash].hooks" ) ?;
74+ let inner = get_or_insert_array ( bash_obj, HOOKS , "hooks.PreToolUse[Bash].hooks" ) ?;
6575
6676 if !inner. iter ( ) . any ( |h| hook_command_matches ( h, hook_command) ) {
6777 let mut entry = Map :: new ( ) ;
68- entry. insert ( "type" . into ( ) , Value :: String ( "command" . into ( ) ) ) ;
69- entry. insert ( "command" . into ( ) , Value :: String ( hook_command. into ( ) ) ) ;
78+ entry. insert ( TYPE . into ( ) , Value :: String ( COMMAND . into ( ) ) ) ;
79+ entry. insert ( COMMAND . into ( ) , Value :: String ( hook_command. into ( ) ) ) ;
7080 inner. push ( Value :: Object ( entry) ) ;
7181 }
7282
7383 Ok ( serialise ( & root) )
7484}
7585
7686/// Inverse of [`merge_hook_entry`]: remove every hook with `command` exactly
77- /// equal to `hook_command`. Cleans up empty arrays/objects so a fresh-install
78- /// settings.json round-trips through install→uninstall to its pre-install shape.
87+ /// equal to `hook_command`, then clean up the empty `Bash` matcher and
88+ /// surrounding `hooks.PreToolUse` / `hooks` containers when they're left
89+ /// empty. After install→uninstall, an `.claude/settings.json` that had no
90+ /// pre-existing hook content returns to `{}`.
91+ ///
92+ /// Round-trip is best-effort, not byte-identical: serialised output uses
93+ /// 2-space pretty-print + trailing newline regardless of pre-install
94+ /// formatting. A pre-existing `{matcher: "Bash", hooks: []}` placeholder
95+ /// is also dropped — we can't distinguish "klasp emptied this" from
96+ /// "user pre-installed an empty placeholder" — but that shape is rare in
97+ /// practice. Non-Bash matchers and Bash matchers with surviving sibling
98+ /// hooks are preserved.
7999///
80- /// Idempotent: running on a settings.json that has no klasp entry returns the
81- /// input unchanged.
100+ /// Idempotent: running on a settings.json that has no klasp entry parses
101+ /// and re-serialises (whitespace may differ but JSON content is unchanged) .
82102pub fn unmerge_hook_entry (
83103 settings_json : & str ,
84104 hook_command : & str ,
@@ -91,12 +111,12 @@ pub fn unmerge_hook_entry(
91111 let mut root: Value = serde_json:: from_str ( trimmed) ?;
92112 let root_obj = expect_object_mut ( & mut root, "" ) ?;
93113
94- let Some ( hooks_val) = root_obj. get_mut ( "hooks" ) else {
114+ let Some ( hooks_val) = root_obj. get_mut ( HOOKS ) else {
95115 return Ok ( serialise ( & root) ) ;
96116 } ;
97- let hooks = expect_object_mut ( hooks_val, "hooks" ) ?;
117+ let hooks = expect_object_mut ( hooks_val, HOOKS ) ?;
98118
99- let Some ( pretool_val) = hooks. get_mut ( "PreToolUse" ) else {
119+ let Some ( pretool_val) = hooks. get_mut ( PRETOOL_USE ) else {
100120 return Ok ( serialise ( & root) ) ;
101121 } ;
102122 let pretool = expect_array_mut ( pretool_val, "hooks.PreToolUse" ) ?;
@@ -105,7 +125,7 @@ pub fn unmerge_hook_entry(
105125 let Some ( matcher_obj) = matcher. as_object_mut ( ) else {
106126 continue ;
107127 } ;
108- let Some ( inner_val) = matcher_obj. get_mut ( "hooks" ) else {
128+ let Some ( inner_val) = matcher_obj. get_mut ( HOOKS ) else {
109129 continue ;
110130 } ;
111131 let Some ( inner) = inner_val. as_array_mut ( ) else {
@@ -114,28 +134,26 @@ pub fn unmerge_hook_entry(
114134 inner. retain ( |h| !hook_command_matches ( h, hook_command) ) ;
115135 }
116136
117- // Sweep up Bash matchers whose `hooks` array is now empty (klasp put
118- // them there in the first place). Untouched matchers — those that
119- // started with sibling hooks — keep their `hooks: []` if a teammate
120- // emptied them, since that's the user's data.
137+ // See the function doc-comment for why pre-existing empty Bash
138+ // placeholders are also swept (provenance is unrecoverable).
121139 pretool. retain ( |m| {
122140 let Some ( obj) = m. as_object ( ) else {
123141 return true ;
124142 } ;
125- if obj. get ( "matcher" ) . and_then ( Value :: as_str) != Some ( "Bash" ) {
143+ if obj. get ( MATCHER ) . and_then ( Value :: as_str) != Some ( BASH ) {
126144 return true ;
127145 }
128146 !matches ! (
129- obj. get( "hooks" ) . and_then( Value :: as_array) ,
147+ obj. get( HOOKS ) . and_then( Value :: as_array) ,
130148 Some ( arr) if arr. is_empty( )
131149 )
132150 } ) ;
133151
134152 if pretool. is_empty ( ) {
135- hooks. remove ( "PreToolUse" ) ;
153+ hooks. remove ( PRETOOL_USE ) ;
136154 }
137155 if hooks. is_empty ( ) {
138- root_obj. remove ( "hooks" ) ;
156+ root_obj. remove ( HOOKS ) ;
139157 }
140158
141159 Ok ( serialise ( & root) )
@@ -174,11 +192,12 @@ fn expect_array_mut<'a>(
174192fn get_or_insert_object < ' a > (
175193 map : & ' a mut Map < String , Value > ,
176194 key : & str ,
195+ path : & str ,
177196) -> Result < & ' a mut Map < String , Value > , SettingsError > {
178197 let entry = map. entry ( key) . or_insert_with ( || Value :: Object ( Map :: new ( ) ) ) ;
179198 let got = describe ( entry) ;
180199 entry. as_object_mut ( ) . ok_or ( SettingsError :: Shape {
181- path : key . to_string ( ) ,
200+ path : path . to_string ( ) ,
182201 expected : "object" ,
183202 got,
184203 } )
@@ -187,20 +206,17 @@ fn get_or_insert_object<'a>(
187206fn get_or_insert_array < ' a > (
188207 map : & ' a mut Map < String , Value > ,
189208 key : & str ,
190- full_path : & str ,
209+ path : & str ,
191210) -> Result < & ' a mut Vec < Value > , SettingsError > {
192211 let entry = map. entry ( key) . or_insert_with ( || Value :: Array ( Vec :: new ( ) ) ) ;
193212 let got = describe ( entry) ;
194213 entry. as_array_mut ( ) . ok_or ( SettingsError :: Shape {
195- path : full_path . to_string ( ) ,
214+ path : path . to_string ( ) ,
196215 expected : "array" ,
197216 got,
198217 } )
199218}
200219
201- /// Find the index of a matcher object in `PreToolUse` whose `matcher` field
202- /// is exactly the string `"Bash"`. Errors only if a matcher entry isn't an
203- /// object (Claude's schema requires it).
204220fn find_bash_matcher ( pretool : & [ Value ] ) -> Result < Option < usize > , SettingsError > {
205221 for ( i, m) in pretool. iter ( ) . enumerate ( ) {
206222 let Some ( obj) = m. as_object ( ) else {
@@ -210,15 +226,15 @@ fn find_bash_matcher(pretool: &[Value]) -> Result<Option<usize>, SettingsError>
210226 got : describe ( m) ,
211227 } ) ;
212228 } ;
213- if obj. get ( "matcher" ) . and_then ( Value :: as_str) == Some ( "Bash" ) {
229+ if obj. get ( MATCHER ) . and_then ( Value :: as_str) == Some ( BASH ) {
214230 return Ok ( Some ( i) ) ;
215231 }
216232 }
217233 Ok ( None )
218234}
219235
220236fn hook_command_matches ( hook : & Value , expected_command : & str ) -> bool {
221- hook. get ( "command" ) . and_then ( Value :: as_str) == Some ( expected_command)
237+ hook. get ( COMMAND ) . and_then ( Value :: as_str) == Some ( expected_command)
222238}
223239
224240fn describe ( v : & Value ) -> & ' static str {
@@ -436,8 +452,6 @@ mod tests {
436452 ) ;
437453 let out = unmerge_hook_entry ( & input, KLASP_CMD ) . unwrap ( ) ;
438454 let v = parse ( & out) ;
439- // hooks key should be gone (the only matcher was klasp's, which we
440- // then dropped because its hooks array became empty).
441455 assert ! ( v. get( "hooks" ) . is_none( ) , "got: {v:#?}" ) ;
442456 }
443457
0 commit comments