Skip to content

Commit 03e6477

Browse files
committed
feat: clean up legacy graphify hooks during install and uninstall
Remove old generated hook scripts and entries when installing or uninstalling graphify for both Claude and Codex platforms. This prevents stale legacy hooks from accumulating in user configurations and ensures a clean migration to the current hook format.
1 parent a143496 commit 03e6477

3 files changed

Lines changed: 126 additions & 29 deletions

File tree

graphify/__main__.py

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1096,27 +1096,47 @@ def _install_codex_hook(project_dir: Path) -> None:
10961096
}
10971097
}
10981098

1099-
pre_tool = existing.setdefault("hooks", {}).setdefault("PreToolUse", [])
1100-
existing["hooks"]["PreToolUse"] = [h for h in pre_tool if "graphify" not in str(h)]
1101-
existing["hooks"]["PreToolUse"].extend(hook_entry["hooks"]["PreToolUse"])
1099+
_remove_graphify_hook_entries(existing)
1100+
hooks = existing.setdefault("hooks", {})
1101+
hooks.setdefault("PreToolUse", []).extend(hook_entry["hooks"]["PreToolUse"])
11021102
hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
11031103
print(f" .codex/hooks.json -> PreToolUse hook registered ({graphify_exe} hook-check)")
11041104

11051105

1106+
def _remove_graphify_hook_entries(settings: dict) -> None:
1107+
"""Remove graphify hook entries while preserving unrelated hooks."""
1108+
hooks = settings.get("hooks")
1109+
if not isinstance(hooks, dict):
1110+
settings["hooks"] = {}
1111+
return
1112+
1113+
for event, event_hooks in list(hooks.items()):
1114+
if not isinstance(event_hooks, list):
1115+
if "graphify" in str(event_hooks):
1116+
hooks.pop(event, None)
1117+
continue
1118+
filtered = [h for h in event_hooks if "graphify" not in str(h)]
1119+
if filtered:
1120+
hooks[event] = filtered
1121+
else:
1122+
hooks.pop(event, None)
1123+
1124+
if not hooks:
1125+
settings.pop("hooks", None)
1126+
1127+
11061128
def _uninstall_codex_hook(project_dir: Path) -> None:
1107-
"""Remove graphify PreToolUse hook from .codex/hooks.json."""
1129+
"""Remove graphify hooks from .codex/hooks.json."""
11081130
hooks_path = project_dir / ".codex" / "hooks.json"
11091131
if not hooks_path.exists():
11101132
return
11111133
try:
11121134
existing = json.loads(hooks_path.read_text(encoding="utf-8"))
11131135
except json.JSONDecodeError:
11141136
return
1115-
pre_tool = existing.get("hooks", {}).get("PreToolUse", [])
1116-
filtered = [h for h in pre_tool if "graphify" not in str(h)]
1117-
existing["hooks"]["PreToolUse"] = filtered
1137+
_remove_graphify_hook_entries(existing)
11181138
hooks_path.write_text(json.dumps(existing, indent=2), encoding="utf-8")
1119-
print(f" .codex/hooks.json -> PreToolUse hook removed")
1139+
print(f" .codex/hooks.json -> graphify hooks removed")
11201140

11211141

11221142
def _agents_install(project_dir: Path, platform: str) -> None:
@@ -1209,24 +1229,18 @@ def claude_install(project_dir: Path | None = None) -> None:
12091229

12101230
def _remove_graphify_claude_hooks(settings: dict) -> None:
12111231
"""Remove graphify Claude hooks while preserving unrelated hooks."""
1212-
hooks = settings.get("hooks")
1213-
if not isinstance(hooks, dict):
1214-
settings["hooks"] = {}
1215-
return
1232+
_remove_graphify_hook_entries(settings)
12161233

1217-
for event in ("UserPromptSubmit", "PreToolUse"):
1218-
event_hooks = hooks.get(event)
1219-
if not isinstance(event_hooks, list):
1220-
hooks.pop(event, None)
1221-
continue
1222-
filtered = [h for h in event_hooks if "graphify" not in str(h)]
1223-
if filtered:
1224-
hooks[event] = filtered
1225-
else:
1226-
hooks.pop(event, None)
12271234

1228-
if not hooks:
1229-
settings.pop("hooks", None)
1235+
def _remove_graphify_claude_hook_scripts(claude_dir: Path) -> list[Path]:
1236+
"""Remove generated graphify Claude hook scripts."""
1237+
removed = []
1238+
for name in ("graphify-guard.py", "graphify-hook.cjs"):
1239+
hook_path = claude_dir / "hooks" / name
1240+
if hook_path.exists():
1241+
hook_path.unlink()
1242+
removed.append(hook_path)
1243+
return removed
12301244

12311245

12321246
def _load_json_object(path: Path) -> dict:
@@ -1251,6 +1265,7 @@ def _install_claude_hook(project_dir: Path) -> None:
12511265
settings_path = claude_dir / "settings.json"
12521266
hooks_dir = claude_dir / "hooks"
12531267
hooks_dir.mkdir(parents=True, exist_ok=True)
1268+
_remove_graphify_claude_hook_scripts(claude_dir)
12541269

12551270
guard_path = hooks_dir / "graphify-guard.py"
12561271
guard_path.write_text(_GRAPHIFY_GUARD_SCRIPT, encoding="utf-8")
@@ -1285,10 +1300,8 @@ def _uninstall_claude_hook(project_dir: Path) -> None:
12851300
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
12861301
print(" .claude/settings.json -> graphify hooks removed")
12871302

1288-
guard_path = claude_dir / "hooks" / "graphify-guard.py"
1289-
if guard_path.exists():
1290-
guard_path.unlink()
1291-
print(" .claude/hooks/graphify-guard.py -> removed")
1303+
for hook_path in _remove_graphify_claude_hook_scripts(claude_dir):
1304+
print(f" {hook_path.relative_to(claude_dir.parent)} -> removed")
12921305

12931306
def claude_uninstall(project_dir: Path | None = None) -> None:
12941307
"""Remove the graphify section from the local CLAUDE.md."""
@@ -1382,7 +1395,7 @@ def main() -> None:
13821395
# Skip during install/uninstall (hook writes trigger a fresh check anyway).
13831396
# Deduplicate paths so platforms sharing the same install dir don't warn twice.
13841397
if not any(arg in ("install", "uninstall") for arg in sys.argv) and (
1385-
len(sys.argv) < 2 or sys.argv[1] not in ("skill", "setup")
1398+
len(sys.argv) < 2 or sys.argv[1] not in ("skill", "setup", "hook-check")
13861399
):
13871400
for skill_dst in {_platform_skill_destination(platform_name) for platform_name in _PLATFORM_CONFIG}:
13881401
_check_skill_version(skill_dst)

tests/test_claude_md.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,13 +179,44 @@ def test_install_preserves_unrelated_settings_hooks(tmp_path):
179179
assert any("graphify-guard.py" in str(h) for h in pre_tool)
180180

181181

182+
def test_install_removes_legacy_claude_hook_script(tmp_path):
183+
"""Installing graphify removes old generated Claude hook scripts."""
184+
hooks_dir = tmp_path / ".claude" / "hooks"
185+
hooks_dir.mkdir(parents=True)
186+
legacy_hook = hooks_dir / "graphify-hook.cjs"
187+
legacy_hook.write_text("console.log('legacy graphify hook')")
188+
settings_path = tmp_path / ".claude" / "settings.json"
189+
settings_path.write_text(json.dumps({
190+
"hooks": {
191+
"UserPromptSubmit": [
192+
{"hooks": [{"type": "command", "command": f"node {legacy_hook} user-prompt"}]},
193+
],
194+
"PreToolUse": [
195+
{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo keep"}]},
196+
{"matcher": "Bash", "hooks": [{"type": "command", "command": f"node {legacy_hook} pre-tool"}]},
197+
],
198+
}
199+
}))
200+
201+
claude_install(tmp_path)
202+
203+
settings = json.loads(settings_path.read_text())
204+
assert not legacy_hook.exists()
205+
assert "graphify-hook.cjs" not in str(settings)
206+
assert "echo keep" in str(settings)
207+
assert (hooks_dir / "graphify-guard.py").exists()
208+
209+
182210
def test_uninstall_removes_settings_hook(tmp_path):
183211
"""claude_uninstall removes graphify hooks and guard script."""
184212
claude_install(tmp_path)
213+
legacy_hook = tmp_path / ".claude" / "hooks" / "graphify-hook.cjs"
214+
legacy_hook.write_text("console.log('legacy graphify hook')")
185215
claude_uninstall(tmp_path)
186216
settings_path = tmp_path / ".claude" / "settings.json"
187217
guard_path = tmp_path / ".claude" / "hooks" / "graphify-guard.py"
188218
assert not guard_path.exists()
219+
assert not legacy_hook.exists()
189220
if settings_path.exists():
190221
settings = json.loads(settings_path.read_text())
191222
hooks = settings.get("hooks", {})

tests/test_install.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for graphify install --platform routing."""
22
from contextlib import contextmanager
3+
import json
34
import os
45
from pathlib import Path
56
import sys
@@ -353,6 +354,58 @@ def test_codex_agents_install_writes_agents_md(tmp_path):
353354
assert "GRAPH_REPORT.md" in agents_md.read_text()
354355

355356

357+
def test_codex_agents_install_removes_legacy_prompt_hook(tmp_path):
358+
hooks_path = tmp_path / ".codex" / "hooks.json"
359+
hooks_path.parent.mkdir(parents=True)
360+
hooks_path.write_text(json.dumps({
361+
"hooks": {
362+
"UserPromptSubmit": [
363+
{"hooks": [{"type": "command", "command": "echo graphify legacy prompt"}]},
364+
{"hooks": [{"type": "command", "command": "echo keep prompt"}]},
365+
],
366+
"PreToolUse": [
367+
{"matcher": "Bash", "hooks": [{"type": "command", "command": "graphify hook-check"}]},
368+
{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo keep tool"}]},
369+
],
370+
}
371+
}))
372+
373+
_agents_install(tmp_path, "codex")
374+
375+
settings = json.loads(hooks_path.read_text())
376+
hooks = settings["hooks"]
377+
assert "legacy prompt" not in str(settings)
378+
assert "keep prompt" in str(hooks.get("UserPromptSubmit", []))
379+
assert "keep tool" in str(hooks.get("PreToolUse", []))
380+
graphify_pre_tool = [h for h in hooks["PreToolUse"] if "hook-check" in str(h)]
381+
assert len(graphify_pre_tool) == 1
382+
383+
384+
def test_codex_uninstall_removes_all_graphify_hooks(tmp_path):
385+
from graphify.__main__ import _uninstall_codex_hook
386+
hooks_path = tmp_path / ".codex" / "hooks.json"
387+
hooks_path.parent.mkdir(parents=True)
388+
hooks_path.write_text(json.dumps({
389+
"hooks": {
390+
"UserPromptSubmit": [
391+
{"hooks": [{"type": "command", "command": "echo graphify legacy prompt"}]},
392+
],
393+
"PreToolUse": [
394+
{"matcher": "Bash", "hooks": [{"type": "command", "command": "graphify hook-check"}]},
395+
],
396+
"PostToolUse": [
397+
{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo keep"}]},
398+
],
399+
}
400+
}))
401+
402+
_uninstall_codex_hook(tmp_path)
403+
404+
settings = json.loads(hooks_path.read_text())
405+
assert "graphify" not in str(settings)
406+
assert "keep" in str(settings)
407+
408+
356409
def test_opencode_agents_install_writes_agents_md(tmp_path):
357410
_agents_install(tmp_path, "opencode")
358411
assert (tmp_path / "AGENTS.md").exists()

0 commit comments

Comments
 (0)