@@ -8,6 +8,7 @@ use crate::integrity;
88
99// Embedded hook script (guards before set -euo pipefail)
1010const REWRITE_HOOK : & str = include_str ! ( "../hooks/rtk-rewrite.sh" ) ;
11+ const POST_TOOL_USE_HOOK : & str = include_str ! ( "../hooks/rtk-post-tool-use.sh" ) ;
1112
1213// Embedded Cursor hook script (preToolUse format)
1314const CURSOR_REWRITE_HOOK : & str = include_str ! ( "../hooks/cursor-rtk-rewrite.sh" ) ;
@@ -282,6 +283,122 @@ pub fn run(
282283 Ok ( ( ) )
283284}
284285
286+ fn prepare_post_tool_use_hook_path ( ) -> Result < PathBuf > {
287+ let claude_dir = resolve_claude_dir ( ) ?;
288+ let hook_dir = claude_dir. join ( "hooks" ) ;
289+ fs:: create_dir_all ( & hook_dir)
290+ . with_context ( || format ! ( "Failed to create hook directory: {}" , hook_dir. display( ) ) ) ?;
291+ Ok ( hook_dir. join ( "rtk-post-tool-use.sh" ) )
292+ }
293+
294+ #[ cfg( unix) ]
295+ fn ensure_post_tool_use_hook_installed ( hook_path : & Path , verbose : u8 ) -> Result < bool > {
296+ let changed = if hook_path. exists ( ) {
297+ let existing = fs:: read_to_string ( hook_path)
298+ . with_context ( || format ! ( "Failed to read existing hook: {}" , hook_path. display( ) ) ) ?;
299+ if existing == POST_TOOL_USE_HOOK {
300+ if verbose > 0 {
301+ eprintln ! ( "PostToolUse hook already up to date: {}" , hook_path. display( ) ) ;
302+ }
303+ false
304+ } else {
305+ fs:: write ( hook_path, POST_TOOL_USE_HOOK )
306+ . with_context ( || format ! ( "Failed to write hook to {}" , hook_path. display( ) ) ) ?;
307+ true
308+ }
309+ } else {
310+ fs:: write ( hook_path, POST_TOOL_USE_HOOK )
311+ . with_context ( || format ! ( "Failed to write hook to {}" , hook_path. display( ) ) ) ?;
312+ true
313+ } ;
314+
315+ use std:: os:: unix:: fs:: PermissionsExt ;
316+ fs:: set_permissions ( hook_path, fs:: Permissions :: from_mode ( 0o755 ) )
317+ . with_context ( || format ! ( "Failed to set hook permissions: {}" , hook_path. display( ) ) ) ?;
318+
319+ Ok ( changed)
320+ }
321+
322+ fn insert_post_tool_use_hook_entry ( root : & mut serde_json:: Value , hook_command : & str ) {
323+ let root_obj = match root. as_object_mut ( ) {
324+ Some ( obj) => obj,
325+ None => {
326+ * root = serde_json:: json!( { } ) ;
327+ root. as_object_mut ( ) . expect ( "Just created object" )
328+ }
329+ } ;
330+
331+ let hooks = root_obj
332+ . entry ( "hooks" )
333+ . or_insert_with ( || serde_json:: json!( { } ) )
334+ . as_object_mut ( )
335+ . expect ( "hooks must be an object" ) ;
336+
337+ let post_tool_use = hooks
338+ . entry ( "PostToolUse" )
339+ . or_insert_with ( || serde_json:: json!( [ ] ) )
340+ . as_array_mut ( )
341+ . expect ( "PostToolUse must be an array" ) ;
342+
343+ post_tool_use. push ( serde_json:: json!( {
344+ "matcher" : "mcp__.*" ,
345+ "hooks" : [ {
346+ "type" : "command" ,
347+ "command" : hook_command
348+ } ]
349+ } ) ) ;
350+ }
351+
352+ fn post_tool_use_hook_already_present ( root : & serde_json:: Value , hook_command : & str ) -> bool {
353+ let arr = match root
354+ . get ( "hooks" )
355+ . and_then ( |h| h. get ( "PostToolUse" ) )
356+ . and_then ( |p| p. as_array ( ) )
357+ {
358+ Some ( a) => a,
359+ None => return false ,
360+ } ;
361+
362+ arr. iter ( )
363+ . filter_map ( |entry| entry. get ( "hooks" ) ?. as_array ( ) )
364+ . flatten ( )
365+ . filter_map ( |hook| hook. get ( "command" ) ?. as_str ( ) )
366+ . any ( |cmd| {
367+ cmd == hook_command
368+ || ( cmd. contains ( "rtk-post-tool-use.sh" )
369+ && hook_command. contains ( "rtk-post-tool-use.sh" ) )
370+ } )
371+ }
372+
373+ fn remove_post_tool_use_hook_entry ( root : & mut serde_json:: Value , hook_command : & str ) {
374+ let hooks = match root. get_mut ( "hooks" ) . and_then ( |h| h. get_mut ( "PostToolUse" ) ) {
375+ Some ( h) => h,
376+ None => return ,
377+ } ;
378+
379+ let arr = match hooks. as_array_mut ( ) {
380+ Some ( a) => a,
381+ None => return ,
382+ } ;
383+
384+ arr. retain ( |entry| {
385+ let hooks_arr = match entry. get ( "hooks" ) . and_then ( |h| h. as_array ( ) ) {
386+ Some ( a) => a,
387+ None => return true ,
388+ } ;
389+ !hooks_arr. iter ( ) . any ( |hook| {
390+ hook. get ( "command" )
391+ . and_then ( |c| c. as_str ( ) )
392+ . map ( |cmd| {
393+ cmd == hook_command
394+ || ( cmd. contains ( "rtk-post-tool-use.sh" )
395+ && hook_command. contains ( "rtk-post-tool-use.sh" ) )
396+ } )
397+ . unwrap_or ( false )
398+ } )
399+ } ) ;
400+ }
401+
285402/// Prepare hook directory and return paths (hook_dir, hook_path)
286403fn prepare_hook_paths ( ) -> Result < ( PathBuf , PathBuf ) > {
287404 let claude_dir = resolve_claude_dir ( ) ?;
@@ -515,6 +632,62 @@ fn remove_hook_from_settings(verbose: u8) -> Result<bool> {
515632 Ok ( removed)
516633}
517634
635+ fn remove_post_tool_use_hook_from_settings ( verbose : u8 ) -> Result < bool > {
636+ let claude_dir = resolve_claude_dir ( ) ?;
637+ let settings_path = claude_dir. join ( "settings.json" ) ;
638+
639+ if !settings_path. exists ( ) {
640+ return Ok ( false ) ;
641+ }
642+
643+ let content = fs:: read_to_string ( & settings_path)
644+ . with_context ( || format ! ( "Failed to read {}" , settings_path. display( ) ) ) ?;
645+
646+ if content. trim ( ) . is_empty ( ) {
647+ return Ok ( false ) ;
648+ }
649+
650+ let mut root: serde_json:: Value = serde_json:: from_str ( & content)
651+ . with_context ( || format ! ( "Failed to parse {} as JSON" , settings_path. display( ) ) ) ?;
652+
653+ let hook_command = resolve_claude_dir ( ) ?
654+ . join ( "hooks" )
655+ . join ( "rtk-post-tool-use.sh" ) ;
656+ let hook_command = hook_command. to_string_lossy ( ) . to_string ( ) ;
657+
658+ let had_entry = post_tool_use_hook_already_present ( & root, & hook_command) ;
659+ if !had_entry {
660+ return Ok ( false ) ;
661+ }
662+
663+ remove_post_tool_use_hook_entry ( & mut root, & hook_command) ;
664+
665+ // Verify removal
666+ let still_present = root
667+ . get ( "hooks" )
668+ . and_then ( |h| h. get ( "PostToolUse" ) )
669+ . and_then ( |p| p. as_array ( ) )
670+ . map ( |a| !a. is_empty ( ) )
671+ . unwrap_or ( false ) ;
672+
673+ let backup_path = settings_path. with_extension ( "json.bak" ) ;
674+ fs:: copy ( & settings_path, & backup_path)
675+ . with_context ( || format ! ( "Failed to backup to {}" , backup_path. display( ) ) ) ?;
676+
677+ let serialized =
678+ serde_json:: to_string_pretty ( & root) . context ( "Failed to serialize settings.json" ) ?;
679+ atomic_write ( & settings_path, & serialized) ?;
680+
681+ if verbose > 0 {
682+ eprintln ! ( "Removed PostToolUse hook from settings.json" ) ;
683+ if still_present {
684+ eprintln ! ( " Note: other PostToolUse hooks remain" ) ;
685+ }
686+ }
687+
688+ Ok ( true )
689+ }
690+
518691/// Full uninstall for Claude, Gemini, Codex, or Cursor artifacts.
519692pub fn uninstall ( global : bool , gemini : bool , codex : bool , cursor : bool , verbose : u8 ) -> Result < ( ) > {
520693 if codex {
@@ -617,6 +790,19 @@ pub fn uninstall(global: bool, gemini: bool, codex: bool, cursor: bool, verbose:
617790 removed. push ( format ! ( "OpenCode plugin: {}" , path. display( ) ) ) ;
618791 }
619792
793+ // Remove PostToolUse hook file
794+ let post_hook_path = claude_dir. join ( "hooks" ) . join ( "rtk-post-tool-use.sh" ) ;
795+ if post_hook_path. exists ( ) {
796+ fs:: remove_file ( & post_hook_path)
797+ . with_context ( || format ! ( "Failed to remove hook: {}" , post_hook_path. display( ) ) ) ?;
798+ removed. push ( format ! ( "PostHook: {}" , post_hook_path. display( ) ) ) ;
799+ }
800+
801+ // Remove PostToolUse entry from settings.json
802+ if remove_post_tool_use_hook_from_settings ( verbose) ? {
803+ removed. push ( "settings.json: removed PostToolUse hook entry" . to_string ( ) ) ;
804+ }
805+
620806 // 6. Remove Cursor hooks
621807 let cursor_removed = remove_cursor_hooks ( verbose) ?;
622808 removed. extend ( cursor_removed) ;
@@ -678,6 +864,54 @@ fn uninstall_codex_at(codex_dir: &Path, verbose: u8) -> Result<Vec<String>> {
678864 Ok ( removed)
679865}
680866
867+ fn patch_settings_json_post_tool_use (
868+ hook_path : & Path ,
869+ _mode : PatchMode ,
870+ verbose : u8 ,
871+ ) -> Result < PatchResult > {
872+ let claude_dir = resolve_claude_dir ( ) ?;
873+ let settings_path = claude_dir. join ( "settings.json" ) ;
874+ let hook_command = hook_path
875+ . to_str ( )
876+ . context ( "Hook path contains invalid UTF-8" ) ?;
877+
878+ let mut root = if settings_path. exists ( ) {
879+ let content = fs:: read_to_string ( & settings_path)
880+ . with_context ( || format ! ( "Failed to read {}" , settings_path. display( ) ) ) ?;
881+ if content. trim ( ) . is_empty ( ) {
882+ serde_json:: json!( { } )
883+ } else {
884+ serde_json:: from_str ( & content)
885+ . with_context ( || format ! ( "Failed to parse {}" , settings_path. display( ) ) ) ?
886+ }
887+ } else {
888+ serde_json:: json!( { } )
889+ } ;
890+
891+ if post_tool_use_hook_already_present ( & root, hook_command) {
892+ if verbose > 0 {
893+ eprintln ! ( "settings.json: PostToolUse hook already present" ) ;
894+ }
895+ return Ok ( PatchResult :: AlreadyPresent ) ;
896+ }
897+
898+ insert_post_tool_use_hook_entry ( & mut root, hook_command) ;
899+
900+ if settings_path. exists ( ) {
901+ let backup_path = settings_path. with_extension ( "json.bak" ) ;
902+ fs:: copy ( & settings_path, & backup_path)
903+ . with_context ( || format ! ( "Failed to backup to {}" , backup_path. display( ) ) ) ?;
904+ }
905+
906+ let serialized =
907+ serde_json:: to_string_pretty ( & root) . context ( "Failed to serialize settings.json" ) ?;
908+ atomic_write ( & settings_path, & serialized) ?;
909+
910+ println ! ( " settings.json: PostToolUse hook added" ) ;
911+
912+ Ok ( PatchResult :: Patched )
913+ }
914+
681915/// Orchestrator: patch settings.json with RTK hook
682916/// Handles reading, checking, prompting, merging, backing up, and atomic writing
683917fn patch_settings_json (
@@ -891,6 +1125,9 @@ fn run_default_mode(
8911125 // 1. Prepare hook directory and install hook
8921126 let ( _hook_dir, hook_path) = prepare_hook_paths ( ) ?;
8931127 let hook_changed = ensure_hook_installed ( & hook_path, verbose) ?;
1128+ let post_hook_path = prepare_post_tool_use_hook_path ( ) ?;
1129+ let _post_hook_changed = ensure_post_tool_use_hook_installed ( & post_hook_path, verbose) ?;
1130+ patch_settings_json_post_tool_use ( & post_hook_path, patch_mode, verbose) ?;
8941131
8951132 // 2. Write RTK.md
8961133 write_if_changed ( & rtk_md_path, RTK_SLIM , "RTK.md" , verbose) ?;
@@ -1030,6 +1267,9 @@ fn run_hook_only_mode(
10301267 // Prepare and install hook
10311268 let ( _hook_dir, hook_path) = prepare_hook_paths ( ) ?;
10321269 let hook_changed = ensure_hook_installed ( & hook_path, verbose) ?;
1270+ let post_hook_path = prepare_post_tool_use_hook_path ( ) ?;
1271+ let _post_hook_changed = ensure_post_tool_use_hook_installed ( & post_hook_path, verbose) ?;
1272+ patch_settings_json_post_tool_use ( & post_hook_path, patch_mode, verbose) ?;
10331273
10341274 let opencode_plugin_path = if install_opencode {
10351275 let path = prepare_opencode_plugin_path ( ) ?;
0 commit comments