Files
shazebandsafishamsi 0224bcaea4 fix(install): fire the search nudge on Claude Code's Grep tool, not just Bash (#1986)
ee1df22 narrowed the Claude Code search-guard matcher from "Glob|Grep" to
"Bash" on the premise that dedicated search tools were removed and searches
go through Bash. Current Claude Code routes content search through its
first-class Grep tool (its Bash tool description actively steers away from
shell grep), so the graphify-first nudge never fired on the agent's primary
exploration path and the graph was silently bypassed.

Three-part fix, per the issue's analysis:

- Matcher: "Bash" -> "Bash|Grep" in _claude_pretooluse_hooks. Glob already
  fires the read nudge via "Read|Glob", so Grep was the only orphaned tool.
- Guard body: the hook-guard search branch only inspected tool_input.command,
  which a Grep call doesn't carry (it has pattern/path/glob). A Grep-shaped
  input (pattern present, no command) is now treated as a search — it IS one
  by definition — and nudges whenever a fresh graph exists. The Bash
  token-matching path is unchanged, and a command-carrying input never
  triggers the Grep shape, so non-search Bash calls stay silent.
- Idempotency: "Bash|Grep" added to the four install/uninstall dedup filters
  (claude + codebuddy), so upgrading replaces the stale "Bash" hook in place
  instead of appending a duplicate — verified against a pre-fix settings.json.

Tests: new regression tests feed Grep-shaped tool_input through
hook-guard search and assert the nudge (with graph), silence (without),
valid PreToolUse JSON, and no blocking; plus a guard that a non-search Bash
command with a stray pattern key does not nudge. Existing matcher assertions
updated across test_search_hook/test_install/test_claude_md/test_codebuddy/
test_hook_strict. Hook+install suites: 397 passed. Full suite: 3224 passed;
the 13 failures are pre-existing on clean v8 in this environment.

Fixes #1986
2026-07-18 19:07:16 +01:00

206 lines
8.2 KiB
Python

"""Tests for graphify claude install / uninstall commands."""
from pathlib import Path
import pytest
from graphify.__main__ import claude_install, claude_uninstall, _CLAUDE_MD_MARKER, _CLAUDE_MD_SECTION
# ---------------------------------------------------------------------------
# install
# ---------------------------------------------------------------------------
def test_install_creates_claude_md(tmp_path):
"""Creates CLAUDE.md when none exists."""
claude_install(tmp_path)
target = tmp_path / "CLAUDE.md"
assert target.exists()
assert _CLAUDE_MD_MARKER in target.read_text()
def test_install_contains_expected_rules(tmp_path):
"""Written section includes the three rules."""
claude_install(tmp_path)
content = (tmp_path / "CLAUDE.md").read_text()
assert "GRAPH_REPORT.md" in content
assert "wiki/index.md" in content
assert "graphify update" in content
def test_install_appends_to_existing_claude_md(tmp_path):
"""Appends to an existing CLAUDE.md without clobbering it."""
target = tmp_path / "CLAUDE.md"
target.write_text("# Existing content\n\nSome rules here.\n")
claude_install(tmp_path)
content = target.read_text()
assert "Existing content" in content
assert _CLAUDE_MD_MARKER in content
def test_install_is_idempotent(tmp_path, capsys):
"""Running install twice does not duplicate the section."""
claude_install(tmp_path)
claude_install(tmp_path)
content = (tmp_path / "CLAUDE.md").read_text()
assert content.count(_CLAUDE_MD_MARKER) == 1
captured = capsys.readouterr()
assert "already configured" in captured.out
def test_install_idempotent_message(tmp_path, capsys):
"""Second install prints the 'already configured' message."""
claude_install(tmp_path)
capsys.readouterr() # clear first call output
claude_install(tmp_path)
out = capsys.readouterr().out
assert "already configured" in out
# ---------------------------------------------------------------------------
# uninstall
# ---------------------------------------------------------------------------
def test_uninstall_removes_section(tmp_path):
"""Removes the graphify section after it was installed."""
claude_install(tmp_path)
claude_uninstall(tmp_path)
target = tmp_path / "CLAUDE.md"
# File may or may not exist depending on whether it was empty
if target.exists():
assert _CLAUDE_MD_MARKER not in target.read_text()
def test_uninstall_preserves_other_content(tmp_path):
"""Uninstall keeps pre-existing content outside the graphify section."""
target = tmp_path / "CLAUDE.md"
target.write_text("# My Project\n\nSome rules.\n")
claude_install(tmp_path)
claude_uninstall(tmp_path)
assert target.exists()
content = target.read_text()
assert "My Project" in content
assert "Some rules" in content
assert _CLAUDE_MD_MARKER not in content
def test_uninstall_no_op_when_not_installed(tmp_path, capsys):
"""Uninstall on a CLAUDE.md without graphify section prints a message and exits cleanly."""
target = tmp_path / "CLAUDE.md"
target.write_text("# Other stuff\n")
claude_uninstall(tmp_path)
out = capsys.readouterr().out
assert "not found" in out or "nothing to do" in out
def test_uninstall_no_op_when_no_file(tmp_path, capsys):
"""Uninstall when no CLAUDE.md exists prints a message and exits cleanly."""
claude_uninstall(tmp_path)
out = capsys.readouterr().out
assert "No CLAUDE.md" in out or "nothing to do" in out
# ---------------------------------------------------------------------------
# settings.json PreToolUse hook
# ---------------------------------------------------------------------------
def test_install_creates_settings_json(tmp_path):
"""claude_install also writes .claude/settings.json with PreToolUse hook."""
import json
claude_install(tmp_path)
settings_path = tmp_path / ".claude" / "settings.json"
assert settings_path.exists()
settings = json.loads(settings_path.read_text())
hooks = settings.get("hooks", {}).get("PreToolUse", [])
assert any(h.get("matcher") == "Bash|Grep" for h in hooks)
def test_install_settings_json_idempotent(tmp_path):
"""Running claude_install twice does not duplicate the PreToolUse hook."""
import json
claude_install(tmp_path)
claude_install(tmp_path)
settings_path = tmp_path / ".claude" / "settings.json"
settings = json.loads(settings_path.read_text())
hooks = settings.get("hooks", {}).get("PreToolUse", [])
bash_hooks = [h for h in hooks if h.get("matcher") == "Bash|Grep" and "graphify" in str(h)]
assert len(bash_hooks) == 1
def test_uninstall_removes_settings_hook(tmp_path):
"""claude_uninstall removes the PreToolUse hook from settings.json."""
import json
claude_install(tmp_path)
claude_uninstall(tmp_path)
settings_path = tmp_path / ".claude" / "settings.json"
if settings_path.exists():
settings = json.loads(settings_path.read_text())
hooks = settings.get("hooks", {}).get("PreToolUse", [])
assert not any(h.get("matcher") == "Bash|Grep" 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