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:
committed by
safishamsi
co-authored by
Claude Opus 4.8
parent
4be0f7b3bf
commit
0d206cbe11
+53
-14
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user