diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8f6dd3c..4fe56c7 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -3,7 +3,7 @@ "name": "codebase-index", "displayName": "Codebase Index", "description": "Give Claude a precise local map of your codebase: find implementations, trace behavior, and predict change impact with file-line evidence.", - "version": "2.1.0", + "version": "2.1.1", "author": { "name": "codebase-index contributors" }, diff --git a/.claude/skills/codebase-index/.skill_version b/.claude/skills/codebase-index/.skill_version index 7ec1d6d..3e3c2f1 100644 --- a/.claude/skills/codebase-index/.skill_version +++ b/.claude/skills/codebase-index/.skill_version @@ -1 +1 @@ -2.1.0 +2.1.1 diff --git a/.codex/skills/codebase-index/.skill_version b/.codex/skills/codebase-index/.skill_version index 7ec1d6d..3e3c2f1 100644 --- a/.codex/skills/codebase-index/.skill_version +++ b/.codex/skills/codebase-index/.skill_version @@ -1 +1 @@ -2.1.0 +2.1.1 diff --git a/.opencode/skills/codebase-index/.skill_version b/.opencode/skills/codebase-index/.skill_version index 7ec1d6d..3e3c2f1 100644 --- a/.opencode/skills/codebase-index/.skill_version +++ b/.opencode/skills/codebase-index/.skill_version @@ -1 +1 @@ -2.1.0 +2.1.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 442ed6c..434af0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ All notable changes to this project are documented here. The format is based on ## [Unreleased] +## [2.1.1] - 2026-09-25 + +### Added + +- `codebase-index install-hooks [--global]` makes Claude Code search through the index + by default. It merges two hooks into `.claude/settings.json` (or + `~/.claude/settings.json`), keeping every other hook: + - `SessionStart`: in an indexed repository, one line of context naming the commands + that answer code questions; + - `PreToolUse` on `Grep|Bash`: the first code search of a session, before any + `codebase-index` command has run, is sent back with the index command to use + instead. Repeating the same call lets it through, the first index command turns the + guard off for the session, and searches over docs, logs or config, pipelines such + as `ps | grep`, and directories without an index are never intercepted. + `CBX_GUARD=0` disables it; `install-hooks --uninstall` removes both hooks. + The hooks run as `codebase-index-hook`, a standard-library-only entry point (about + 0.1 s per call). The Claude Code plugin registers the same hooks. + ## [2.1.0] - 2026-09-24 Agent-efficiency release. An agent answering five code questions on a 5.9k-file @@ -868,7 +886,8 @@ Pooled over 305 queries (Python, Java, TypeScript), v1.8.0 → 1.9.0: - Hooks example + `watch` mode for keeping the index fresh without blocking the edit loop (M8). - `doctor`, `stats`, `clean` diagnostics/maintenance commands. -[Unreleased]: https://github.com/denfry/codebase-index/compare/v2.1.0...HEAD +[Unreleased]: https://github.com/denfry/codebase-index/compare/v2.1.1...HEAD +[2.1.1]: https://github.com/denfry/codebase-index/compare/v2.1.0...v2.1.1 [2.1.0]: https://github.com/denfry/codebase-index/compare/v2.0.0...v2.1.0 [2.0.0]: https://github.com/denfry/codebase-index/compare/v1.9.0...v2.0.0 [1.10.0]: https://github.com/denfry/codebase-index/compare/v1.9.0...abb67df diff --git a/README.md b/README.md index ba3aa4d..6ad5799 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,10 @@ agent format: `path:start-end symbol` with the matching lines numbered underneat | **Any MCP client** (Claude Desktop, Cursor, VS Code, Zed, Windsurf, ...) | `pip install "codebase-index[mcp]"` then `codebase-index mcp --root /path/to/repo` | 11 tools (`search_code`, `find_refs`, `impact_of`, `impact_of_diff`, `path_between`, ...) with a versioned JSON envelope | | **Anything with a shell** | `codebase-index --json ...` | The same payloads as plain JSON, plus a local SQLite database you can query yourself | -`init --target auto` detects which of these are present. See +`init --target auto` detects which of these are present. To make Claude Code reach for +the index before Grep in every indexed repository, run +`codebase-index install-hooks --global`: a session-start note plus a guard that sends the +first code search of a session to the index (repeat the call to run it anyway). See [INSTALLATION.md](docs/INSTALLATION.md) and [MCP.md](docs/MCP.md). ## Evidence diff --git a/docs/installer.md b/docs/installer.md index 458c3b5..129ed9b 100644 --- a/docs/installer.md +++ b/docs/installer.md @@ -110,7 +110,7 @@ pwsh ./install.ps1 -Target claude -InstallDir "D:\skills\codebase-index" **Pin to a branch or tag** (reproducibility and safety): ```sh -sh install.sh --branch v2.1.0 +sh install.sh --branch v2.1.1 ``` --- diff --git a/hooks/hooks.json b/hooks/hooks.json index 886df9d..bd59d21 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -6,6 +6,23 @@ { "type": "command", "command": "\"${CLAUDE_PLUGIN_ROOT}/scripts/bootstrap.sh\"" + }, + { + "type": "command", + "command": "codebase-index-hook session-start 2>/dev/null || true", + "timeout": 5 + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Grep|Bash", + "hooks": [ + { + "type": "command", + "command": "codebase-index-hook guard 2>/dev/null || true", + "timeout": 5 } ] } diff --git a/pyproject.toml b/pyproject.toml index 4761765..2e61137 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ build = ["build>=1.2", "twine>=5.0"] codebase-index = "codebase_index.cli:app" cbx = "codebase_index.cli:app" # short alias codebase-index-mcp = "codebase_index.mcp.server:run" # standalone MCP entry point +codebase-index-hook = "codebase_index.hooks:main" # Claude Code hooks (stdlib only, fast) [project.urls] Homepage = "https://github.com/denfry/codebase-index" diff --git a/requirements.lock b/requirements.lock index 084a720..d0f2b53 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,3 +1,3 @@ -codebase-index @ https://github.com/denfry/codebase-index/archive/refs/tags/v2.1.0.tar.gz +codebase-index @ https://github.com/denfry/codebase-index/archive/refs/tags/v2.1.1.tar.gz tree-sitter==0.25.2 tree-sitter-language-pack==1.8.1 diff --git a/src/codebase_index/__init__.py b/src/codebase_index/__init__.py index 40ce013..593777e 100644 --- a/src/codebase_index/__init__.py +++ b/src/codebase_index/__init__.py @@ -4,4 +4,4 @@ See docs/ARCHITECTURE.md for the module map. """ -__version__ = "2.1.0" +__version__ = "2.1.1" diff --git a/src/codebase_index/cli.py b/src/codebase_index/cli.py index 9d61b2c..85170f4 100644 --- a/src/codebase_index/cli.py +++ b/src/codebase_index/cli.py @@ -1064,6 +1064,41 @@ def watch( raise typer.Exit(code=1) +@app.command("install-hooks") +def install_hooks( + ctx: typer.Context, + global_: bool = typer.Option( + False, "--global", help="Install into ~/.claude/settings.json (every project)." + ), + uninstall: bool = typer.Option(False, "--uninstall", help="Remove the hooks instead."), +) -> None: + """Make Claude Code search through the index by default. + + Adds a SessionStart note and a PreToolUse guard on Grep/Bash: the first code + search of a session, before any codebase-index command, is sent back with the + index command to run instead; repeating it lets it through. Both do nothing in + a directory without an index. Other hooks in the settings file are kept. + """ + from .config import find_root + from . import scaffold + + if global_: + path = Path.home() / ".claude" / "settings.json" + else: + root_opt = ctx.obj.get("root") if ctx.obj else None + path = (Path(root_opt).resolve() if root_opt else find_root()) / scaffold.SETTINGS_REL + if uninstall: + changed = scaffold.uninstall_guard_hooks(path) + verb = "removed from" + else: + changed = scaffold.install_guard_hooks(path) + verb = "added to" + if changed: + typer.echo(f"[install-hooks] {', '.join(changed)} {verb} {path}") + else: + typer.echo(f"[install-hooks] nothing to change in {path}") + + @app.command("skill-update") def skill_update( ctx: typer.Context, diff --git a/src/codebase_index/hooks.py b/src/codebase_index/hooks.py new file mode 100644 index 0000000..a57dfb2 --- /dev/null +++ b/src/codebase_index/hooks.py @@ -0,0 +1,256 @@ +"""Claude Code hooks that make the index the default way to search code. + +Two events, both no-ops outside a repository that has an index: + +- ``session-start`` adds one short note to the session context: this repository + is indexed, and which commands answer code questions. +- ``guard`` (PreToolUse on Grep and Bash) intercepts a code search the first time + in a session, before any ``codebase-index`` command has run, and names the + index command to use instead. It is a nudge, not a wall: repeating the same + call lets it through, the first ``codebase-index`` command in the session turns + the guard off, and searches over docs, logs or config are never intercepted. + +Measured on a 5.9k-file monorepo, an agent spent 19% fewer tokens with the index +than with grep (tests/eval/results/2026-09-24-agent-pilot.md), but agents reach +for Grep out of habit. The guard runs on every Bash call, so this module imports +only the standard library and does no index work beyond one small SQLite read. + +Entry point: ``codebase-index-hook `` reads the hook JSON on stdin and +prints the hook JSON reply (or nothing). ``CBX_GUARD=0`` disables the guard. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import sqlite3 +import sys +import time +from pathlib import Path +from typing import Any, Optional + +INDEX_REL = Path(".claude") / "cache" / "codebase-index" / "index.sqlite" +_STATE_DIR = "hook-sessions" +_STATE_TTL_S = 2 * 24 * 3600 + +# A shell segment that searches file contents: the first command of the segment +# (after `&&`, `||`, `;` or the start), so `ps aux | grep x` is not a repo search. +_SEARCH_CMD = re.compile( + r"(?:^|&&|\|\||;)\s*(?:cd\s+\S+\s*&&\s*)?" + r"(?Pgit\s+grep|grep|egrep|fgrep|rg|ag|ack|findstr|Select-String)\b(?P[^|;&]*)", + re.IGNORECASE, +) +_INDEX_CMD = re.compile(r"(?:^|[\s;&|/\\\"'])(?:codebase-index|cbx)(?:\.exe|\.ps1)?\s+\w") +# Searches over prose, logs and config are what grep is for; leave them alone. +_NON_CODE = re.compile( + r"\.(?:md|mdx|rst|txt|log|ya?ml|json|toml|ini|cfg|conf|csv|xml|properties|lock|env|html?)\b" + r"|--include=\*?\.(?:md|txt|log|ya?ml|json|toml)", + re.IGNORECASE, +) +_NON_CODE_TYPES = {"md", "markdown", "txt", "log", "yaml", "yml", "json", "toml", "xml", + "csv", "ini", "config"} + + +def main(argv: Optional[list[str]] = None) -> int: + args = sys.argv[1:] if argv is None else argv + event = args[0] if args else "" + try: + payload = json.loads(sys.stdin.read() or "{}") + except (ValueError, OSError): + return 0 + try: + reply = {"guard": guard, "session-start": session_start}.get(event, _none)(payload) + except Exception: # a hook must never break the session it runs in + return 0 + if reply: + sys.stdout.write(json.dumps(reply)) + return 0 + + +def _none(_payload: dict) -> Optional[dict]: + return None + + +def find_index_root(start: Path) -> Optional[Path]: + """The nearest directory at or above `start` that holds an index.""" + for path in (start, *start.parents): + if (path / INDEX_REL).is_file(): + return path + return None + + +def _cwd(payload: dict) -> Path: + raw = str(payload.get("cwd") or os.getcwd()) + # Git Bash hands out MSYS paths (/c/Projects/x); Python on Windows needs C:/. + msys = re.match(r"^/([a-zA-Z])(/.*)?$", raw) + if os.name == "nt" and msys: + raw = f"{msys.group(1)}:{msys.group(2) or '/'}" + return Path(raw) + + +# --- session-start ------------------------------------------------------------------ + + +def session_start(payload: dict) -> Optional[dict]: + root = find_index_root(_cwd(payload)) + if root is None: + return None + files = _indexed_files(root) + size = f" ({files} files)" if files else "" + note = ( + f"This repository has a codebase-index index{size}. For questions about the " + "code (where something is, how it works, who calls it, what a change breaks) " + "use the codebase-index skill before Grep or Read: " + '`codebase-index search "" --compact`, ' + '`codebase-index refs "Owner.member" --compact`, ' + '`codebase-index impact "Owner.member" --compact`. ' + "Output lines are numbered; cite them as file:line." + ) + return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": note}} + + +def _indexed_files(root: Path) -> Optional[int]: + try: + conn = sqlite3.connect(f"file:{root / INDEX_REL}?mode=ro", uri=True, timeout=0.5) + try: + return int(conn.execute("SELECT COUNT(*) FROM files").fetchone()[0]) + finally: + conn.close() + except sqlite3.Error: + return None + + +# --- guard ------------------------------------------------------------------------------ + + +def guard(payload: dict) -> Optional[dict]: + if os.environ.get("CBX_GUARD", "").strip() == "0": + return None + tool = payload.get("tool_name") or "" + tool_input = payload.get("tool_input") or {} + if tool not in ("Grep", "Bash"): + return None + root = find_index_root(_cwd(payload)) + if root is None: + return None + session = str(payload.get("session_id") or "default") + state = _State(root, session) + + if tool == "Bash": + command = str(tool_input.get("command") or "") + if _INDEX_CMD.search(command): + state.mark_used() + return None + pattern = _shell_search_pattern(command) + if pattern is None: + return None + else: + if _grep_targets_non_code(tool_input): + return None + pattern = str(tool_input.get("pattern") or "") + + if state.used: + return None + fingerprint = hashlib.sha1( + (tool + json.dumps(tool_input, sort_keys=True, default=str)).encode("utf-8") + ).hexdigest() + if state.was_denied(fingerprint): + return None # the retry after a nudge: the agent decided grep is right + state.deny(fingerprint) + return { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "deny", + "permissionDecisionReason": _reason(pattern), + } + } + + +def _shell_search_pattern(command: str) -> Optional[str]: + """The search term of a content search in `command`, or None if there is none.""" + for match in _SEARCH_CMD.finditer(command): + args = match.group("args") + if _NON_CODE.search(args): + continue + quoted = re.search(r"""(['"])(.+?)\1""", args) + if quoted: + return quoted.group(2) + words = [w for w in args.split() if not w.startswith("-")] + return words[0] if words else "" + return None + + +def _grep_targets_non_code(tool_input: dict) -> bool: + kind = str(tool_input.get("type") or "").lower() + if kind in _NON_CODE_TYPES: + return True + target = f"{tool_input.get('glob') or ''} {tool_input.get('path') or ''}" + return bool(_NON_CODE.search(target)) + + +def _reason(pattern: str) -> str: + term = re.sub(r"[\\^$()\[\]{}|*+?]", " ", pattern).strip() or "" + term = " ".join(term.split())[:80] + return ( + "codebase-index: this repository is indexed, and the index answers code " + "searches with ranked, numbered lines in fewer tokens than grep. Run it first:\n" + f' codebase-index search "{term}" --compact # where / how\n' + ' codebase-index refs "Owner.member" --compact # every call site, with callers\n' + ' codebase-index symbol "Name" # a definition\n' + "If the index does not answer, repeat this exact call and it will run. Once any " + "codebase-index command has run in this session, searches are not intercepted." + ) + + +class _State: + """Per-session guard state: whether the index was used, which calls were nudged.""" + + def __init__(self, root: Path, session: str) -> None: + safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session)[:80] or "default" + self.dir = root / INDEX_REL.parent / _STATE_DIR + self.path = self.dir / f"{safe}.json" + self.data: dict[str, Any] = {"used": False, "denied": []} + try: + self.data.update(json.loads(self.path.read_text(encoding="utf-8"))) + except (OSError, ValueError): + pass + + @property + def used(self) -> bool: + return bool(self.data.get("used")) + + def was_denied(self, fingerprint: str) -> bool: + return fingerprint in self.data.get("denied", []) + + def mark_used(self) -> None: + if not self.used: + self.data["used"] = True + self._save() + + def deny(self, fingerprint: str) -> None: + self.data.setdefault("denied", []).append(fingerprint) + self._save() + + def _save(self) -> None: + try: + self.dir.mkdir(parents=True, exist_ok=True) + self.path.write_text(json.dumps(self.data), encoding="utf-8") + _prune(self.dir) + except OSError: + pass + + +def _prune(directory: Path) -> None: + cutoff = time.time() - _STATE_TTL_S + for item in directory.glob("*.json"): + try: + if item.stat().st_mtime < cutoff: + item.unlink() + except OSError: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/codebase_index/scaffold.py b/src/codebase_index/scaffold.py index 72a65c4..4862f14 100644 --- a/src/codebase_index/scaffold.py +++ b/src/codebase_index/scaffold.py @@ -283,6 +283,67 @@ def merge_hook_settings(root: Path) -> bool: return True +# Index-first hooks (hooks.py): a SessionStart note and a PreToolUse guard that +# steers the first code search of a session to the index. +_GUARD_MARKER = "codebase-index-hook" +GUARD_HOOKS: dict[str, dict] = { + "SessionStart": { + "hooks": [{"type": "command", "command": "codebase-index-hook session-start", + "timeout": 5}], + }, + "PreToolUse": { + "matcher": "Grep|Bash", + "hooks": [{"type": "command", "command": "codebase-index-hook guard", "timeout": 5}], + }, +} + + +def _is_guard_entry(entry: dict) -> bool: + return any(_GUARD_MARKER in str(h.get("command", "")) for h in entry.get("hooks", [])) + + +def install_guard_hooks(settings_path: Path) -> list[str]: + """Merge the index-first hooks into a Claude Code settings file. + + Idempotent, and leaves every other hook in place. Returns the events added. + """ + settings: dict = {} + if settings_path.exists(): + settings = json.loads(settings_path.read_text(encoding="utf-8") or "{}") + hooks = settings.setdefault("hooks", {}) + added = [] + for event, entry in GUARD_HOOKS.items(): + entries = hooks.setdefault(event, []) + if not any(_is_guard_entry(e) for e in entries): + entries.append(json.loads(json.dumps(entry))) + added.append(event) + if added: + settings_path.parent.mkdir(parents=True, exist_ok=True) + settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") + return added + + +def uninstall_guard_hooks(settings_path: Path) -> list[str]: + """Remove the index-first hooks; other hooks are kept. Returns the events changed.""" + if not settings_path.exists(): + return [] + settings = json.loads(settings_path.read_text(encoding="utf-8") or "{}") + hooks = settings.get("hooks", {}) + removed = [] + for event in GUARD_HOOKS: + entries = hooks.get(event, []) + kept = [e for e in entries if not _is_guard_entry(e)] + if len(kept) != len(entries): + removed.append(event) + if kept: + hooks[event] = kept + else: + hooks.pop(event, None) + if removed: + settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8") + return removed + + # ── MCP client config helpers ────────────────────────────────────────────────────────────────── _MCP_SERVER_NAME = "codebase-index" diff --git a/tests/test_hooks_guard.py b/tests/test_hooks_guard.py new file mode 100644 index 0000000..86365a8 --- /dev/null +++ b/tests/test_hooks_guard.py @@ -0,0 +1,120 @@ +"""Index-first hooks: a SessionStart note and a PreToolUse guard (hooks.py).""" + +from __future__ import annotations + +import io +import json +from pathlib import Path + +import pytest + +from codebase_index import hooks, scaffold + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + db = tmp_path / "repo" / hooks.INDEX_REL + db.parent.mkdir(parents=True) + import sqlite3 + + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE files (id INTEGER PRIMARY KEY)") + conn.executemany("INSERT INTO files VALUES (?)", [(i,) for i in range(3)]) + conn.commit() + conn.close() + return tmp_path / "repo" + + +def _call(root: Path, tool: str, session: str = "s1", **tool_input): + return hooks.guard({"session_id": session, "cwd": str(root / "src"), + "tool_name": tool, "tool_input": tool_input}) + + +def _denied(reply) -> bool: + return bool(reply) and reply["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_session_start_notes_the_index_only_where_one_exists(repo: Path, tmp_path: Path): + reply = hooks.session_start({"cwd": str(repo)}) + context = reply["hookSpecificOutput"]["additionalContext"] + assert "(3 files)" in context and "--compact" in context + assert hooks.session_start({"cwd": str(tmp_path)}) is None + + +def test_first_code_search_is_sent_to_the_index_and_its_retry_runs(repo: Path): + first = _call(repo, "Grep", pattern="TownService.refresh") + assert _denied(first) + reason = first["hookSpecificOutput"]["permissionDecisionReason"] + assert 'codebase-index search "TownService.refresh" --compact' in reason + assert _call(repo, "Grep", pattern="TownService.refresh") is None # the retry + assert _denied(_call(repo, "Grep", pattern="somethingElse")) # a new search + + +def test_using_the_index_turns_the_guard_off_for_the_session(repo: Path): + assert _call(repo, "Bash", command="cd x && codebase-index refs A.b --compact") is None + assert _call(repo, "Grep", pattern="anything") is None + assert _call(repo, "Bash", command="rg -n foo src") is None + assert _denied(_call(repo, "Grep", session="other", pattern="anything")) + + +@pytest.mark.parametrize("command", [ + "grep -rn TODO docs/*.md", + "ps aux | grep java", + "git log --oneline | grep fix", + "ls -la", + "rg -n error build.log", +]) +def test_non_code_and_non_search_shell_commands_pass(repo: Path, command: str): + assert _call(repo, "Bash", command=command) is None + + +@pytest.mark.parametrize("command", [ + "grep -rn treasurerDeparted --include=*.java .", + "cd /c/Projects/x && rg -n 'TownService.refresh' realism-polity", + "git grep -n place", +]) +def test_code_searches_in_the_shell_are_guarded(repo: Path, command: str): + assert _denied(_call(repo, "Bash", command=command)) + + +def test_grep_over_docs_or_config_passes(repo: Path): + assert _call(repo, "Grep", pattern="x", glob="**/*.yml") is None + assert _call(repo, "Grep", pattern="x", type="md") is None + + +def test_no_index_or_disabled_guard_never_intercepts(repo: Path, tmp_path: Path, monkeypatch): + assert _call(tmp_path, "Grep", pattern="x") is None + monkeypatch.setenv("CBX_GUARD", "0") + assert _call(repo, "Grep", pattern="x") is None + + +def test_msys_cwd_is_understood(repo: Path, monkeypatch): + monkeypatch.setattr(hooks.os, "name", "nt") + drive, rest = str(repo).replace("\\", "/").split(":", 1) if ":" in str(repo) else ("", "") + if not drive: + pytest.skip("POSIX path has no drive letter") + assert hooks._cwd({"cwd": f"/{drive.lower()}{rest}"}) == Path(f"{drive}:{rest}") + + +def test_main_reads_stdin_and_writes_the_reply(repo: Path, monkeypatch, capsys): + payload = {"session_id": "m", "cwd": str(repo), "tool_name": "Grep", + "tool_input": {"pattern": "x"}} + monkeypatch.setattr("sys.stdin", io.StringIO(json.dumps(payload))) + assert hooks.main(["guard"]) == 0 + assert json.loads(capsys.readouterr().out)["hookSpecificOutput"]["permissionDecision"] == "deny" + monkeypatch.setattr("sys.stdin", io.StringIO("not json")) + assert hooks.main(["guard"]) == 0 and capsys.readouterr().out == "" + + +def test_install_merges_idempotently_and_uninstall_keeps_other_hooks(tmp_path: Path): + path = tmp_path / "settings.json" + other = {"type": "command", "command": "my-other-hook"} + path.write_text(json.dumps({"model": "x", "hooks": { + "PreToolUse": [{"matcher": "Bash", "hooks": [other]}]}}), encoding="utf-8") + assert scaffold.install_guard_hooks(path) == ["SessionStart", "PreToolUse"] + assert scaffold.install_guard_hooks(path) == [] + data = json.loads(path.read_text(encoding="utf-8")) + assert data["model"] == "x" and len(data["hooks"]["PreToolUse"]) == 2 + assert scaffold.uninstall_guard_hooks(path) == ["SessionStart", "PreToolUse"] + data = json.loads(path.read_text(encoding="utf-8")) + assert data["hooks"] == {"PreToolUse": [{"matcher": "Bash", "hooks": [other]}]}