1515
1616from __future__ import annotations
1717
18- import importlib .metadata
18+ import importlib .resources
1919import json
2020import pathlib
2121import shutil
2727from mergify_cli import utils
2828
2929
30+ def _get_claude_hooks_dir () -> pathlib .Path :
31+ """Get the global directory for Claude hook scripts."""
32+ return pathlib .Path .home () / ".config" / "mergify-cli" / "claude-hooks"
33+
34+
35+ def _get_claude_settings_file () -> pathlib .Path :
36+ """Get the global Claude settings file path."""
37+ return pathlib .Path .home () / ".claude" / "settings.json"
38+
39+
3040async def _install_hook (hooks_dir : pathlib .Path , hook_name : str ) -> None :
3141 installed_hook_file = hooks_dir / hook_name
3242
@@ -54,98 +64,94 @@ async def _install_hook(hooks_dir: pathlib.Path, hook_name: str) -> None:
5464 installed_hook_file .chmod (0o755 )
5565
5666
57- async def _install_claude_hooks (project_dir : pathlib . Path ) -> None :
67+ def _install_claude_hooks () -> None :
5868 """Install Claude Code hooks for session ID tracking.
5969
60- Uses settings.local.json (gitignored) rather than settings.json
61- so each user must run setup, similar to git hooks.
70+ Installs hooks globally:
71+ - Scripts: ~/.config/mergify-cli/claude-hooks/
72+ - Settings: ~/.claude/settings.json
6273 """
63- claude_dir = project_dir / ".claude"
64- claude_hooks_dir = claude_dir / "hooks"
65-
66- # Create directories if they don't exist
74+ claude_hooks_dir = _get_claude_hooks_dir ()
6775 claude_hooks_dir .mkdir (parents = True , exist_ok = True )
6876
69- # Ensure hooks directory is gitignored
70- gitignore_file = claude_dir / ".gitignore"
71- hooks_pattern = "hooks/"
72- if gitignore_file .exists ():
73- async with aiofiles .open (gitignore_file ) as f :
74- gitignore_content = await f .read ()
75- if hooks_pattern not in gitignore_content .splitlines ():
76- async with aiofiles .open (gitignore_file , "a" ) as f :
77- await f .write (f"{ hooks_pattern } \n " )
78- console .log ("Added hooks/ to .claude/.gitignore" )
79- else :
80- async with aiofiles .open (gitignore_file , "w" ) as f :
81- await f .write (f"{ hooks_pattern } \n " )
82- console .log ("Created .claude/.gitignore with hooks/" )
77+ # Install hook scripts
78+ claude_hooks_src = importlib .resources .files (__package__ ).joinpath ("claude_hooks" )
79+ for src_file in claude_hooks_src .iterdir ():
80+ if not src_file .name .endswith (".sh" ):
81+ continue
8382
84- # Load our hook configuration
85- new_settings_file = str (
86- importlib .resources .files (__package__ ).joinpath ("claude_hooks/settings.json" ),
87- )
88- async with aiofiles .open (new_settings_file ) as f :
89- new_settings = json .loads (await f .read ())
83+ dest_file = claude_hooks_dir / src_file .name
84+ src_path = str (src_file )
85+
86+ if dest_file .exists ():
87+ installed_content = dest_file .read_text (encoding = "utf-8" )
88+ new_content = pathlib .Path (src_path ).read_text (encoding = "utf-8" )
89+ if installed_content == new_content :
90+ console .log (f"Claude hook script is up to date: { src_file .name } " )
91+ continue
92+
93+ console .log (f"Installing Claude hook script: { src_file .name } " )
94+ shutil .copy (src_path , dest_file )
95+ dest_file .chmod (0o755 )
96+
97+ # Install/update Claude settings
98+ settings_file = _get_claude_settings_file ()
99+ settings_file .parent .mkdir (parents = True , exist_ok = True )
90100
91- # Merge into settings.local.json (user-local, gitignored)
92- settings_file = claude_dir / "settings.local.json"
93101 if settings_file .exists ():
94- async with aiofiles .open (settings_file ) as f :
95- try :
96- existing_settings = json .loads (await f .read ())
97- except json .JSONDecodeError :
98- existing_settings = {}
102+ try :
103+ existing_settings = json .loads (
104+ settings_file .read_text (encoding = "utf-8" ),
105+ )
106+ except json .JSONDecodeError :
107+ existing_settings = {}
99108 else :
100109 existing_settings = {}
101110
102- # Merge hooks - add our SessionStart hook if not already present
103111 if "hooks" not in existing_settings :
104112 existing_settings ["hooks" ] = {}
105113
106- our_hook = new_settings ["hooks" ]["SessionStart" ]
114+ # Build our hook configuration with absolute path
115+ hook_script_path = str (claude_hooks_dir / "session-start.sh" )
116+ our_hook = [
117+ {
118+ "hooks" : [
119+ {
120+ "type" : "command" ,
121+ "command" : hook_script_path ,
122+ },
123+ ],
124+ },
125+ ]
126+
107127 existing_hooks = existing_settings ["hooks" ].get ("SessionStart" , [])
108128
109129 # Check if our hook is already installed (by checking the command)
110- our_command = our_hook [0 ]["hooks" ][0 ]["command" ]
111130 already_installed = any (
112- hook .get ("hooks" , [{}])[0 ].get ("command" ) == our_command
131+ hook .get ("hooks" , [{}])[0 ].get ("command" ) == hook_script_path
113132 for hook in existing_hooks
114133 if hook .get ("hooks" )
115134 )
116135
117136 if already_installed :
118- console .log ("Claude settings.local. json hook is up to date" )
137+ console .log ("Claude settings.json hook is up to date" )
119138 else :
120- existing_settings ["hooks" ]["SessionStart" ] = existing_hooks + our_hook
121- async with aiofiles .open (settings_file , "w" ) as f :
122- await f .write (json .dumps (existing_settings , indent = 2 ) + "\n " )
123- console .log ("Installation of Claude settings.local.json hook" )
124-
125- # Install session-start.sh hook script
126- hook_file = claude_hooks_dir / "session-start.sh"
127- new_hook_file = str (
128- importlib .resources .files (__package__ ).joinpath (
129- "claude_hooks/session-start.sh" ,
130- ),
131- )
132-
133- if hook_file .exists ():
134- async with aiofiles .open (hook_file ) as f :
135- data_installed = await f .read ()
136- async with aiofiles .open (new_hook_file ) as f :
137- data_new = await f .read ()
138- if data_installed == data_new :
139- console .log ("Claude session-start.sh hook is up to date" )
140- else :
141- console .print (
142- f"warning: { hook_file } differs from mergify_cli hook, skipping" ,
143- style = "yellow" ,
139+ # Remove any old mergify-cli hooks that might reference different paths
140+ filtered_hooks = [
141+ hook
142+ for hook in existing_hooks
143+ if not (
144+ hook .get ("hooks" , [{}])[0 ]
145+ .get ("command" , "" )
146+ .endswith ("session-start.sh" )
144147 )
145- else :
146- console .log ("Installation of Claude session-start.sh hook" )
147- shutil .copy (new_hook_file , hook_file )
148- hook_file .chmod (0o755 )
148+ ]
149+ existing_settings ["hooks" ]["SessionStart" ] = filtered_hooks + our_hook
150+ settings_file .write_text (
151+ json .dumps (existing_settings , indent = 2 ) + "\n " ,
152+ encoding = "utf-8" ,
153+ )
154+ console .log ("Installation of Claude settings.json hook" )
149155
150156
151157async def stack_setup () -> None :
@@ -154,6 +160,5 @@ async def stack_setup() -> None:
154160 await _install_hook (hooks_dir , "commit-msg" )
155161 await _install_hook (hooks_dir , "prepare-commit-msg" )
156162
157- # Install Claude hooks for session ID tracking
158- project_dir = pathlib .Path (await utils .git ("rev-parse" , "--show-toplevel" ))
159- await _install_claude_hooks (project_dir )
163+ # Install Claude hooks for session ID tracking (global)
164+ _install_claude_hooks ()
0 commit comments