codebase-index 2.1.0__py3-none-any.whl → 2.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,4 +4,4 @@ Public surface is intentionally small; the CLI (`codebase_index.cli:app`) is the
4
4
  See docs/ARCHITECTURE.md for the module map.
5
5
  """
6
6
 
7
- __version__ = "2.1.0"
7
+ __version__ = "2.1.2"
codebase_index/cli.py CHANGED
@@ -1064,6 +1064,41 @@ def watch(
1064
1064
  raise typer.Exit(code=1)
1065
1065
 
1066
1066
 
1067
+ @app.command("install-hooks")
1068
+ def install_hooks(
1069
+ ctx: typer.Context,
1070
+ global_: bool = typer.Option(
1071
+ False, "--global", help="Install into ~/.claude/settings.json (every project)."
1072
+ ),
1073
+ uninstall: bool = typer.Option(False, "--uninstall", help="Remove the hooks instead."),
1074
+ ) -> None:
1075
+ """Make Claude Code search through the index by default.
1076
+
1077
+ Adds a SessionStart note and a PreToolUse guard on Grep/Bash: the first code
1078
+ search of a session, before any codebase-index command, is sent back with the
1079
+ index command to run instead; repeating it lets it through. Both do nothing in
1080
+ a directory without an index. Other hooks in the settings file are kept.
1081
+ """
1082
+ from .config import find_root
1083
+ from . import scaffold
1084
+
1085
+ if global_:
1086
+ path = Path.home() / ".claude" / "settings.json"
1087
+ else:
1088
+ root_opt = ctx.obj.get("root") if ctx.obj else None
1089
+ path = (Path(root_opt).resolve() if root_opt else find_root()) / scaffold.SETTINGS_REL
1090
+ if uninstall:
1091
+ changed = scaffold.uninstall_guard_hooks(path)
1092
+ verb = "removed from"
1093
+ else:
1094
+ changed = scaffold.install_guard_hooks(path)
1095
+ verb = "added to"
1096
+ if changed:
1097
+ typer.echo(f"[install-hooks] {', '.join(changed)} {verb} {path}")
1098
+ else:
1099
+ typer.echo(f"[install-hooks] nothing to change in {path}")
1100
+
1101
+
1067
1102
  @app.command("skill-update")
1068
1103
  def skill_update(
1069
1104
  ctx: typer.Context,
@@ -0,0 +1,272 @@
1
+ """Claude Code hooks that make the index the default way to search code.
2
+
3
+ Two events, both no-ops outside a repository that has an index:
4
+
5
+ - ``session-start`` adds one short note to the session context: this repository
6
+ is indexed, and which commands answer code questions.
7
+ - ``guard`` (PreToolUse on Grep and Bash) intercepts a code search the first time
8
+ in a session, before any ``codebase-index`` command has run, and names the
9
+ index command to use instead. It is a nudge, not a wall: searching for the
10
+ same term again lets it through, the first ``codebase-index`` command in the
11
+ session turns the guard off (even one that has yet to build the index), and
12
+ searches over docs, logs or config, or inside heredoc bodies, are never
13
+ intercepted.
14
+
15
+ Measured on a 5.9k-file monorepo, an agent spent 19% fewer tokens with the index
16
+ than with grep (tests/eval/results/2026-09-24-agent-pilot.md), but agents reach
17
+ for Grep out of habit. The guard runs on every Bash call, so this module imports
18
+ only the standard library and does no index work beyond one small SQLite read.
19
+
20
+ Entry point: ``codebase-index-hook <event>`` reads the hook JSON on stdin and
21
+ prints the hook JSON reply (or nothing). ``CBX_GUARD=0`` disables the guard.
22
+ Per-session state lives in the system temp directory (``CBX_HOOK_STATE``
23
+ overrides it), so it exists before the repository has an index.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import hashlib
29
+ import json
30
+ import os
31
+ import re
32
+ import sqlite3
33
+ import sys
34
+ import tempfile
35
+ import time
36
+ from pathlib import Path
37
+ from typing import Any, Optional
38
+
39
+ INDEX_REL = Path(".claude") / "cache" / "codebase-index" / "index.sqlite"
40
+ _STATE_DIR = "codebase-index-hooks"
41
+ _STATE_TTL_S = 2 * 24 * 3600
42
+
43
+ # A shell segment that searches file contents: the first command of the segment
44
+ # (after `&&`, `||`, `;`, a newline or the start), so `ps aux | grep x` is not a
45
+ # repo search. Quoted arguments may hold `|`, as in grep "a\|b".
46
+ _SEARCH_CMD = re.compile(
47
+ r"(?:^|&&|\|\||;|\n)[ \t]*(?:cd\s+\S+\s*&&\s*)?"
48
+ r"(?P<cmd>git\s+grep|grep|egrep|fgrep|rg|ag|ack|findstr|Select-String)\b"
49
+ r"""(?P<args>(?:"[^"\n]*"|'[^'\n]*'|[^|;&\n"'])*)""",
50
+ re.IGNORECASE,
51
+ )
52
+ # A heredoc body is file content (a script being written, stdin for python), not
53
+ # commands; an unterminated one runs to the end of the command.
54
+ _HEREDOC = re.compile(
55
+ r"""(?<!<)<<-?(?!<)[ \t]*(['"]?)([A-Za-z_][\w-]*)\1(?P<rest>[^\n]*)\n"""
56
+ r".*?(?:^[ \t]*\2[ \t]*$|\Z)",
57
+ re.DOTALL | re.MULTILINE,
58
+ )
59
+ _INDEX_CMD = re.compile(r"(?:^|[\s;&|/\\\"'])(?:codebase-index|cbx)(?:\.exe|\.ps1)?\s+\w")
60
+ # Searches over prose, logs and config are what grep is for; leave them alone.
61
+ _NON_CODE = re.compile(
62
+ r"\.(?:md|mdx|rst|txt|log|ya?ml|json|toml|ini|cfg|conf|csv|xml|properties|lock|env|html?)\b"
63
+ r"|--include=\*?\.(?:md|txt|log|ya?ml|json|toml)",
64
+ re.IGNORECASE,
65
+ )
66
+ _NON_CODE_TYPES = {"md", "markdown", "txt", "log", "yaml", "yml", "json", "toml", "xml",
67
+ "csv", "ini", "config"}
68
+
69
+
70
+ def main(argv: Optional[list[str]] = None) -> int:
71
+ args = sys.argv[1:] if argv is None else argv
72
+ event = args[0] if args else ""
73
+ try:
74
+ payload = json.loads(sys.stdin.read() or "{}")
75
+ except (ValueError, OSError):
76
+ return 0
77
+ try:
78
+ reply = {"guard": guard, "session-start": session_start}.get(event, _none)(payload)
79
+ except Exception: # a hook must never break the session it runs in
80
+ return 0
81
+ if reply:
82
+ sys.stdout.write(json.dumps(reply))
83
+ return 0
84
+
85
+
86
+ def _none(_payload: dict) -> Optional[dict]:
87
+ return None
88
+
89
+
90
+ def find_index_root(start: Path) -> Optional[Path]:
91
+ """The nearest directory at or above `start` that holds an index."""
92
+ for path in (start, *start.parents):
93
+ if (path / INDEX_REL).is_file():
94
+ return path
95
+ return None
96
+
97
+
98
+ def _cwd(payload: dict) -> Path:
99
+ raw = str(payload.get("cwd") or os.getcwd())
100
+ # Git Bash hands out MSYS paths (/c/Projects/x); Python on Windows needs C:/.
101
+ msys = re.match(r"^/([a-zA-Z])(/.*)?$", raw)
102
+ if os.name == "nt" and msys:
103
+ raw = f"{msys.group(1)}:{msys.group(2) or '/'}"
104
+ return Path(raw)
105
+
106
+
107
+ # --- session-start ------------------------------------------------------------------
108
+
109
+
110
+ def session_start(payload: dict) -> Optional[dict]:
111
+ root = find_index_root(_cwd(payload))
112
+ if root is None:
113
+ return None
114
+ files = _indexed_files(root)
115
+ size = f" ({files} files)" if files else ""
116
+ note = (
117
+ f"This repository has a codebase-index index{size}. For questions about the "
118
+ "code (where something is, how it works, who calls it, what a change breaks) "
119
+ "use the codebase-index skill before Grep or Read: "
120
+ '`codebase-index search "<question>" --compact`, '
121
+ '`codebase-index refs "Owner.member" --compact`, '
122
+ '`codebase-index impact "Owner.member" --compact`. '
123
+ "Output lines are numbered; cite them as file:line."
124
+ )
125
+ return {"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": note}}
126
+
127
+
128
+ def _indexed_files(root: Path) -> Optional[int]:
129
+ try:
130
+ conn = sqlite3.connect(f"file:{root / INDEX_REL}?mode=ro", uri=True, timeout=0.5)
131
+ try:
132
+ return int(conn.execute("SELECT COUNT(*) FROM files").fetchone()[0])
133
+ finally:
134
+ conn.close()
135
+ except sqlite3.Error:
136
+ return None
137
+
138
+
139
+ # --- guard ------------------------------------------------------------------------------
140
+
141
+
142
+ def guard(payload: dict) -> Optional[dict]:
143
+ if os.environ.get("CBX_GUARD", "").strip() == "0":
144
+ return None
145
+ tool = payload.get("tool_name") or ""
146
+ tool_input = payload.get("tool_input") or {}
147
+ if tool not in ("Grep", "Bash"):
148
+ return None
149
+ state = _State(str(payload.get("session_id") or "default"))
150
+
151
+ if tool == "Bash":
152
+ command = _HEREDOC.sub(lambda m: "<<" + m.group("rest"), str(tool_input.get("command") or ""))
153
+ if _INDEX_CMD.search(command):
154
+ # Checked before the index exists: the first search is what builds it.
155
+ state.mark_used()
156
+ return None
157
+ pattern = _shell_search_pattern(command)
158
+ if pattern is None:
159
+ return None
160
+ else:
161
+ if _grep_targets_non_code(tool_input):
162
+ return None
163
+ pattern = str(tool_input.get("pattern") or "")
164
+
165
+ if state.used or find_index_root(_cwd(payload)) is None:
166
+ return None
167
+ # Keyed by the search term, not the whole call: a retry rarely repeats the
168
+ # command byte for byte (a new description, another pipe, a regenerated script).
169
+ fingerprint = hashlib.sha1(_term(pattern).lower().encode("utf-8")).hexdigest()
170
+ if state.was_denied(fingerprint):
171
+ return None # the retry after a nudge: the agent decided grep is right
172
+ state.deny(fingerprint)
173
+ return {
174
+ "hookSpecificOutput": {
175
+ "hookEventName": "PreToolUse",
176
+ "permissionDecision": "deny",
177
+ "permissionDecisionReason": _reason(pattern),
178
+ }
179
+ }
180
+
181
+
182
+ def _shell_search_pattern(command: str) -> Optional[str]:
183
+ """The search term of a content search in `command`, or None if there is none."""
184
+ for match in _SEARCH_CMD.finditer(command):
185
+ args = match.group("args")
186
+ if _NON_CODE.search(args):
187
+ continue
188
+ quoted = re.search(r"""(['"])(.+?)\1""", args)
189
+ if quoted:
190
+ return quoted.group(2)
191
+ words = [w for w in args.split() if not w.startswith("-")]
192
+ return words[0] if words else ""
193
+ return None
194
+
195
+
196
+ def _grep_targets_non_code(tool_input: dict) -> bool:
197
+ kind = str(tool_input.get("type") or "").lower()
198
+ if kind in _NON_CODE_TYPES:
199
+ return True
200
+ target = f"{tool_input.get('glob') or ''} {tool_input.get('path') or ''}"
201
+ return bool(_NON_CODE.search(target))
202
+
203
+
204
+ def _term(pattern: str) -> str:
205
+ """A regex search pattern as plain query words."""
206
+ return " ".join(re.sub(r"""[\\^$()\[\]{}|*+?"']""", " ", pattern).split())[:80]
207
+
208
+
209
+ def _reason(pattern: str) -> str:
210
+ term = _term(pattern) or "<what you are looking for>"
211
+ return (
212
+ "codebase-index: this repository is indexed, and the index answers code "
213
+ "searches with ranked, numbered lines in fewer tokens than grep. Run it first:\n"
214
+ f' codebase-index search "{term}" --compact # where / how\n'
215
+ ' codebase-index refs "Owner.member" --compact # every call site, with callers\n'
216
+ ' codebase-index symbol "Name" # a definition\n'
217
+ "If the index does not answer, search for the same term again and it will run. "
218
+ "Once any codebase-index command has run in this session, searches are not "
219
+ "intercepted."
220
+ )
221
+
222
+
223
+ class _State:
224
+ """Per-session guard state: whether the index was used, which calls were nudged."""
225
+
226
+ def __init__(self, session: str) -> None:
227
+ safe = re.sub(r"[^A-Za-z0-9_.-]", "_", session)[:80] or "default"
228
+ self.dir = Path(os.environ.get("CBX_HOOK_STATE") or Path(tempfile.gettempdir()) / _STATE_DIR)
229
+ self.path = self.dir / f"{safe}.json"
230
+ self.data: dict[str, Any] = {"used": False, "denied": []}
231
+ try:
232
+ self.data.update(json.loads(self.path.read_text(encoding="utf-8")))
233
+ except (OSError, ValueError):
234
+ pass
235
+
236
+ @property
237
+ def used(self) -> bool:
238
+ return bool(self.data.get("used"))
239
+
240
+ def was_denied(self, fingerprint: str) -> bool:
241
+ return fingerprint in self.data.get("denied", [])
242
+
243
+ def mark_used(self) -> None:
244
+ if not self.used:
245
+ self.data["used"] = True
246
+ self._save()
247
+
248
+ def deny(self, fingerprint: str) -> None:
249
+ self.data.setdefault("denied", []).append(fingerprint)
250
+ self._save()
251
+
252
+ def _save(self) -> None:
253
+ try:
254
+ self.dir.mkdir(parents=True, exist_ok=True)
255
+ self.path.write_text(json.dumps(self.data), encoding="utf-8")
256
+ _prune(self.dir)
257
+ except OSError:
258
+ pass
259
+
260
+
261
+ def _prune(directory: Path) -> None:
262
+ cutoff = time.time() - _STATE_TTL_S
263
+ for item in directory.glob("*.json"):
264
+ try:
265
+ if item.stat().st_mtime < cutoff:
266
+ item.unlink()
267
+ except OSError:
268
+ pass
269
+
270
+
271
+ if __name__ == "__main__":
272
+ raise SystemExit(main())
@@ -283,6 +283,67 @@ def merge_hook_settings(root: Path) -> bool:
283
283
  return True
284
284
 
285
285
 
286
+ # Index-first hooks (hooks.py): a SessionStart note and a PreToolUse guard that
287
+ # steers the first code search of a session to the index.
288
+ _GUARD_MARKER = "codebase-index-hook"
289
+ GUARD_HOOKS: dict[str, dict] = {
290
+ "SessionStart": {
291
+ "hooks": [{"type": "command", "command": "codebase-index-hook session-start",
292
+ "timeout": 5}],
293
+ },
294
+ "PreToolUse": {
295
+ "matcher": "Grep|Bash",
296
+ "hooks": [{"type": "command", "command": "codebase-index-hook guard", "timeout": 5}],
297
+ },
298
+ }
299
+
300
+
301
+ def _is_guard_entry(entry: dict) -> bool:
302
+ return any(_GUARD_MARKER in str(h.get("command", "")) for h in entry.get("hooks", []))
303
+
304
+
305
+ def install_guard_hooks(settings_path: Path) -> list[str]:
306
+ """Merge the index-first hooks into a Claude Code settings file.
307
+
308
+ Idempotent, and leaves every other hook in place. Returns the events added.
309
+ """
310
+ settings: dict = {}
311
+ if settings_path.exists():
312
+ settings = json.loads(settings_path.read_text(encoding="utf-8") or "{}")
313
+ hooks = settings.setdefault("hooks", {})
314
+ added = []
315
+ for event, entry in GUARD_HOOKS.items():
316
+ entries = hooks.setdefault(event, [])
317
+ if not any(_is_guard_entry(e) for e in entries):
318
+ entries.append(json.loads(json.dumps(entry)))
319
+ added.append(event)
320
+ if added:
321
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
322
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
323
+ return added
324
+
325
+
326
+ def uninstall_guard_hooks(settings_path: Path) -> list[str]:
327
+ """Remove the index-first hooks; other hooks are kept. Returns the events changed."""
328
+ if not settings_path.exists():
329
+ return []
330
+ settings = json.loads(settings_path.read_text(encoding="utf-8") or "{}")
331
+ hooks = settings.get("hooks", {})
332
+ removed = []
333
+ for event in GUARD_HOOKS:
334
+ entries = hooks.get(event, [])
335
+ kept = [e for e in entries if not _is_guard_entry(e)]
336
+ if len(kept) != len(entries):
337
+ removed.append(event)
338
+ if kept:
339
+ hooks[event] = kept
340
+ else:
341
+ hooks.pop(event, None)
342
+ if removed:
343
+ settings_path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
344
+ return removed
345
+
346
+
286
347
  # ── MCP client config helpers ──────────────────────────────────────────────────────────────────
287
348
 
288
349
  _MCP_SERVER_NAME = "codebase-index"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codebase-index
3
- Version: 2.1.0
3
+ Version: 2.1.2
4
4
  Summary: A precise local codebase map for AI coding agents: find, trace, and predict with file-line evidence.
5
5
  Project-URL: Homepage, https://github.com/denfry/codebase-index
6
6
  Project-URL: Documentation, https://github.com/denfry/codebase-index/tree/main/docs
@@ -195,7 +195,10 @@ agent format: `path:start-end symbol` with the matching lines numbered underneat
195
195
  | **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 |
196
196
  | **Anything with a shell** | `codebase-index --json ...` | The same payloads as plain JSON, plus a local SQLite database you can query yourself |
197
197
 
198
- `init --target auto` detects which of these are present. See
198
+ `init --target auto` detects which of these are present. To make Claude Code reach for
199
+ the index before Grep in every indexed repository, run
200
+ `codebase-index install-hooks --global`: a session-start note plus a guard that sends the
201
+ first code search of a session to the index (repeat the call to run it anyway). See
199
202
  [INSTALLATION.md](docs/INSTALLATION.md) and [MCP.md](docs/MCP.md).
200
203
 
201
204
  ## Evidence
@@ -1,10 +1,11 @@
1
- codebase_index/__init__.py,sha256=YXBNCFRX7GgcDfiGMhw8iEeH0kjn1qMQGF2xbKx1MtI,240
1
+ codebase_index/__init__.py,sha256=pnBspkpuL4MUFufQ9vEX2aLCoarg52kQNO3dUB0zP6E,240
2
2
  codebase_index/__main__.py,sha256=wcCrL4PjG51r5wVKqJhcoJPTLfHW0wNbD31DrUN0MWI,28
3
- codebase_index/cli.py,sha256=cT1xLZZf2lwV5yPHRRzYWhLP_O8pZBTQd6FLeVHbo6s,43693
3
+ codebase_index/cli.py,sha256=3HisRXo0yEyWp4X3PNE1mLoJcGdLr42o8mcnhUqKVxo,45052
4
4
  codebase_index/config.py,sha256=piyZ6zr2guEgMOPTJOabMLfk97be7sisTyXRVTdHvEk,4322
5
5
  codebase_index/doctor.py,sha256=ely35q-NNfGYtRbnyyDjM0Y2UZgJ5tl8iw1KCP2wzKM,6517
6
+ codebase_index/hooks.py,sha256=MR3rNfuBWL60wK1jFo5lK0ZYlUlOSSEpwyWuPkv0kC0,10294
6
7
  codebase_index/models.py,sha256=v9dkcNYz2IsgSmuWqopp8JQ38seqWic9mNpO-nRpqCQ,5734
7
- codebase_index/scaffold.py,sha256=oMoT_uI6DhdM5dN9SLbnHnmbcB11YDqbOxTyaydt3_Y,14598
8
+ codebase_index/scaffold.py,sha256=s-mwX_O1gsdXGByLA-AlFWcitnkZhQAIFNqW2xJTV4M,16867
8
9
  codebase_index/service.py,sha256=k9zm2IqF0qs0DVZau0P8s3FrgvosyiMsSrixpgrXIFs,18024
9
10
  codebase_index/skill_update.py,sha256=h8h_sDBXFklw5BSYbj4tuLp4O297nZWSXvEXsf7hiD8,4763
10
11
  codebase_index/discovery/__init__.py,sha256=ktlylA5XvkPsDtPDjaw8E4PVByedhfNY186B7T0bUmk,518
@@ -74,8 +75,8 @@ codebase_index/storage/repo.py,sha256=F6MQDo23zwXECb0lI40oZ6Mz-uH9EKwUvIsBEGK4C9
74
75
  codebase_index/storage/schema.sql,sha256=JSltVJebOmPxqA6rtP3-6TgTgGT0vQkQKLCt948njCc,5544
75
76
  codebase_index/watch/__init__.py,sha256=Mc--hdTGZlf7s7UrtZ8zAf35EGvxBY9paXcuVVzA-j4,242
76
77
  codebase_index/watch/watcher.py,sha256=f895qNBZkC6fCbEUWnxdpTH0Gy4RfFdg_hxLWJ9HZlc,3135
77
- codebase_index-2.1.0.dist-info/METADATA,sha256=PzEcmVAvNQ1z0rCSoSIOX9Nb0XZrnW14ntqgJgaBSSI,17187
78
- codebase_index-2.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
79
- codebase_index-2.1.0.dist-info/entry_points.txt,sha256=0UAH5H-m1dvx9a7aKnIA4G2r6Nf4U93i-5p_Fm3XUXo,138
80
- codebase_index-2.1.0.dist-info/licenses/LICENSE,sha256=UkoUuqQfpFu4eib8UkTVAraPFC44A7pAIS7Jb23fNu8,1084
81
- codebase_index-2.1.0.dist-info/RECORD,,
78
+ codebase_index-2.1.2.dist-info/METADATA,sha256=ZrMCH9qRhCyC8VgCyAaZU2c5NXNFFmvCkES3Nj9TnuE,17442
79
+ codebase_index-2.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
80
+ codebase_index-2.1.2.dist-info/entry_points.txt,sha256=TOy1PPDDP2vv0sd6skbkZFBxEDmEiSx0YspY0mF4fbI,186
81
+ codebase_index-2.1.2.dist-info/licenses/LICENSE,sha256=UkoUuqQfpFu4eib8UkTVAraPFC44A7pAIS7Jb23fNu8,1084
82
+ codebase_index-2.1.2.dist-info/RECORD,,
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
2
  cbx = codebase_index.cli:app
3
3
  codebase-index = codebase_index.cli:app
4
+ codebase-index-hook = codebase_index.hooks:main
4
5
  codebase-index-mcp = codebase_index.mcp.server:run