diff --git a/graphify/__main__.py b/graphify/__main__.py index b869e8653..9aa2cf806 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -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: diff --git a/tests/test_claude_md.py b/tests/test_claude_md.py index f81f10dd3..55b13e5ee 100644 --- a/tests/test_claude_md.py +++ b/tests/test_claude_md.py @@ -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