fix(uninstall): remove graphify hook/section from Claude local-only files (#1731)

`graphify uninstall` (and `graphify claude uninstall`) only looked at
`.claude/settings.json` and root `CLAUDE.md`. A user who relocates the
PreToolUse hook into `.claude/settings.local.json` and the `## graphify`
instructions into `CLAUDE.local.md` / `.claude/CLAUDE.local.md` - so they
are not committed to a shared repo - was left with both behind after
uninstall, which reported "nothing to do".

- `_uninstall_claude_hook` now strips the hook from both `settings.json`
  and `settings.local.json` (factored into `_strip_graphify_hook`).
- `claude_uninstall` now strips the `## graphify` section from `CLAUDE.md`,
  root `CLAUDE.local.md`, and `.claude/CLAUDE.local.md` (factored into
  `_strip_graphify_md_section`), cleaning every present file rather than the
  first, and always runs the hook cleanup.
- `_strip_graphify_md_section` reads the two newly-covered files defensively
  so a non-UTF-8 or otherwise unreadable file can't abort uninstall.

Unrelated local-only files (no graphify content) are left byte-for-byte
untouched. The `--local` install flag suggested in the issue is left out of
scope; this change makes uninstall symmetric with wherever the config lives.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
tpateeq
2026-07-08 16:38:35 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 4be0f7b3bf
commit 0d206cbe11
2 changed files with 122 additions and 14 deletions
+53 -14
View File
@@ -2053,8 +2053,19 @@ def _install_claude_hook(project_dir: Path) -> None:
def _uninstall_claude_hook(project_dir: Path) -> None:
"""Remove graphify PreToolUse hook from .claude/settings.json."""
settings_path = project_dir / ".claude" / "settings.json"
"""Remove the graphify PreToolUse hook from .claude/settings.json and its
local-only sibling .claude/settings.local.json.
A user may relocate the hook into settings.local.json so it is not committed
to a shared repo, so uninstall has to clean whichever file holds it (#1731).
"""
claude_dir = project_dir / ".claude"
for name in ("settings.json", "settings.local.json"):
_strip_graphify_hook(claude_dir / name)
def _strip_graphify_hook(settings_path: Path) -> None:
"""Drop graphify PreToolUse hooks from a single Claude settings file, if present."""
if not settings_path.exists():
return
try:
@@ -2067,7 +2078,7 @@ def _uninstall_claude_hook(project_dir: Path) -> None:
return
settings["hooks"]["PreToolUse"] = filtered
settings_path.write_text(json.dumps(settings, indent=2), encoding="utf-8")
print(f" .claude/settings.json -> PreToolUse hook removed")
print(f" .claude/{settings_path.name} -> PreToolUse hook removed")
def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
@@ -2116,25 +2127,54 @@ def uninstall_all(project_dir: Path | None = None, purge: bool = False) -> None:
def claude_uninstall(project_dir: Path | None = None, *, project: bool = False) -> None:
"""Remove the graphify skill tree (SKILL.md + references/) and the CLAUDE.md section.
"""Remove the graphify skill tree (SKILL.md + references/) and the graphify
section from CLAUDE.md and its local-only variants, plus the PreToolUse hook.
Mirrors gemini_uninstall: the bare `graphify uninstall` and `graphify claude
uninstall` must remove the installed skill, not just strip CLAUDE.md, or the
progressive-disclosure tree (SKILL.md + references/) is orphaned (#1121).
A user may relocate the section/hook into the local-only files Claude Code
supports so they are not committed to a shared repo, so uninstall also cleans
CLAUDE.local.md, .claude/CLAUDE.local.md and .claude/settings.local.json (#1731).
"""
project_dir = project_dir or Path(".")
_remove_skill_file("claude", project=project, project_dir=project_dir)
target = project_dir / "CLAUDE.md"
if not target.exists():
md_targets = [
project_dir / "CLAUDE.md",
project_dir / "CLAUDE.local.md",
project_dir / ".claude" / "CLAUDE.local.md",
]
existing = [t for t in md_targets if t.exists()]
removed_any = False
for target in existing:
# Not short-circuited: every present file must be cleaned, not just the first.
if _strip_graphify_md_section(target):
removed_any = True
if not existing:
print("No CLAUDE.md found in current directory - nothing to do")
return
content = target.read_text(encoding="utf-8")
if _CLAUDE_MD_MARKER not in content:
elif not removed_any:
print("graphify section not found in CLAUDE.md - nothing to do")
return
_uninstall_claude_hook(project_dir)
def _strip_graphify_md_section(target: Path) -> bool:
"""Strip the ## graphify section from one CLAUDE.md-style file.
Returns True if a section was removed. Deletes the file if nothing else
remains after removal.
"""
try:
content = target.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# An unreadable/undecodable CLAUDE.md-style file (e.g. non-UTF-8, or a
# directory of that name) must not abort uninstall - nothing to strip.
return False
if _CLAUDE_MD_MARKER not in content:
return False
# Remove the ## graphify section: from the marker to the next ## heading or EOF
cleaned = re.sub(
r"\n*## graphify\n.*?(?=\n## |\Z)",
@@ -2147,9 +2187,8 @@ def claude_uninstall(project_dir: Path | None = None, *, project: bool = False)
print(f"graphify section removed from {target.resolve()}")
else:
target.unlink()
print(f"CLAUDE.md was empty after removal - deleted {target.resolve()}")
_uninstall_claude_hook(project_dir or Path("."))
print(f"{target.name} was empty after removal - deleted {target.resolve()}")
return True
def codebuddy_install(project_dir: Path | None = None) -> None:
+69
View File
@@ -134,3 +134,72 @@ def test_uninstall_removes_settings_hook(tmp_path):
settings = json.loads(settings_path.read_text())
hooks = settings.get("hooks", {}).get("PreToolUse", [])
assert not any(h.get("matcher") == "Bash" and "graphify" in str(h) for h in hooks)
# ---------------------------------------------------------------------------
# local-only variants: settings.local.json / CLAUDE.local.md (#1731)
# ---------------------------------------------------------------------------
def test_uninstall_removes_hook_from_settings_local_json(tmp_path):
"""A hook relocated to .claude/settings.local.json is removed on uninstall."""
import json
claude_install(tmp_path)
# User moved the hook out of the committed settings.json into the local-only file.
(tmp_path / ".claude" / "settings.json").rename(tmp_path / ".claude" / "settings.local.json")
claude_uninstall(tmp_path)
local = tmp_path / ".claude" / "settings.local.json"
hooks = json.loads(local.read_text()).get("hooks", {}).get("PreToolUse", [])
assert not any("graphify" in str(h) for h in hooks)
def test_uninstall_removes_section_from_dot_claude_local_md(tmp_path):
"""Instructions relocated to .claude/CLAUDE.local.md are removed on uninstall."""
claude_install(tmp_path)
local_md = tmp_path / ".claude" / "CLAUDE.local.md"
local_md.write_text((tmp_path / "CLAUDE.md").read_text())
(tmp_path / "CLAUDE.md").unlink()
claude_uninstall(tmp_path)
assert not local_md.exists() or _CLAUDE_MD_MARKER not in local_md.read_text()
def test_uninstall_removes_section_from_root_claude_local_md(tmp_path):
"""Instructions relocated to root CLAUDE.local.md are removed on uninstall."""
claude_install(tmp_path)
local_md = tmp_path / "CLAUDE.local.md"
local_md.write_text((tmp_path / "CLAUDE.md").read_text())
(tmp_path / "CLAUDE.md").unlink()
claude_uninstall(tmp_path)
assert not local_md.exists() or _CLAUDE_MD_MARKER not in local_md.read_text()
def test_uninstall_cleans_both_standard_and_local(tmp_path):
"""When the section lives in both CLAUDE.md and a local variant, both are cleaned."""
claude_install(tmp_path)
claude_md = tmp_path / "CLAUDE.md"
local_md = tmp_path / ".claude" / "CLAUDE.local.md"
local_md.write_text(claude_md.read_text()) # duplicated into the local file too
claude_uninstall(tmp_path)
for f in (claude_md, local_md):
assert not f.exists() or _CLAUDE_MD_MARKER not in f.read_text()
def test_uninstall_preserves_other_content_in_local_md(tmp_path):
"""Uninstall keeps non-graphify content in CLAUDE.local.md."""
claude_install(tmp_path)
local_md = tmp_path / ".claude" / "CLAUDE.local.md"
local_md.write_text("# Local notes\n\nkeep me\n\n" + (tmp_path / "CLAUDE.md").read_text())
claude_uninstall(tmp_path)
assert local_md.exists()
content = local_md.read_text()
assert "Local notes" in content
assert "keep me" in content
assert _CLAUDE_MD_MARKER not in content
def test_uninstall_tolerates_unreadable_local_md(tmp_path):
"""A non-UTF-8 CLAUDE.local.md must not abort uninstall (it has no marker to strip)."""
claude_install(tmp_path)
local_md = tmp_path / ".claude" / "CLAUDE.local.md"
local_md.write_bytes(b"\xff\xfe not valid utf-8 \x80\x81")
claude_uninstall(tmp_path) # must not raise
assert local_md.read_bytes() == b"\xff\xfe not valid utf-8 \x80\x81" # left untouched