Skip to content

Commit ca34ab5

Browse files
jdclaude
andcommitted
refactor(stack): install Claude hooks globally
Move Claude hooks from per-project to global installation: - Scripts: ~/.config/mergify-cli/claude-hooks/ - Settings: ~/.claude/settings.json This eliminates the need for per-project gitignore handling and ensures hooks work across all projects automatically. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Change-Id: I3c458313822df489627119561bcc84572c5e9873 Claude-Session-Id: 5f1a97a3-70f4-4099-8edf-810289af8151
1 parent 18a63cb commit ca34ab5

3 files changed

Lines changed: 76 additions & 86 deletions

File tree

.claude/.gitignore

Lines changed: 0 additions & 1 deletion
This file was deleted.

mergify_cli/stack/claude_hooks/settings.json

Lines changed: 0 additions & 14 deletions
This file was deleted.

mergify_cli/stack/setup.py

Lines changed: 76 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from __future__ import annotations
1717

18-
import importlib.metadata
18+
import importlib.resources
1919
import json
2020
import pathlib
2121
import shutil
@@ -27,6 +27,16 @@
2727
from 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+
3040
async 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

151157
async 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

Comments
 (0)