Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 53 additions & 14 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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)",
Expand All @@ -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:
Expand Down
69 changes: 69 additions & 0 deletions tests/test_claude_md.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading