codebase-index 1.6.0__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.
- codebase_index/__init__.py +7 -0
- codebase_index/__main__.py +3 -0
- codebase_index/cli.py +916 -0
- codebase_index/config.py +110 -0
- codebase_index/discovery/__init__.py +10 -0
- codebase_index/discovery/classify.py +151 -0
- codebase_index/discovery/ignore.py +58 -0
- codebase_index/discovery/walker.py +75 -0
- codebase_index/doctor.py +138 -0
- codebase_index/embeddings/__init__.py +2 -0
- codebase_index/embeddings/backend.py +67 -0
- codebase_index/embeddings/external.py +56 -0
- codebase_index/embeddings/local.py +41 -0
- codebase_index/embeddings/noop.py +15 -0
- codebase_index/graph/__init__.py +8 -0
- codebase_index/graph/analysis.py +468 -0
- codebase_index/graph/builder.py +160 -0
- codebase_index/graph/expand.py +136 -0
- codebase_index/graph/export.py +381 -0
- codebase_index/graph/navigate.py +201 -0
- codebase_index/indexer/__init__.py +8 -0
- codebase_index/indexer/doc_chunks.py +202 -0
- codebase_index/indexer/freshness.py +109 -0
- codebase_index/indexer/pipeline.py +423 -0
- codebase_index/mcp/__init__.py +2 -0
- codebase_index/mcp/server.py +354 -0
- codebase_index/models.py +145 -0
- codebase_index/output/__init__.py +6 -0
- codebase_index/output/json.py +13 -0
- codebase_index/output/markdown.py +316 -0
- codebase_index/output/redact.py +31 -0
- codebase_index/parsers/__init__.py +9 -0
- codebase_index/parsers/base.py +47 -0
- codebase_index/parsers/languages.py +290 -0
- codebase_index/parsers/line_chunker.py +39 -0
- codebase_index/parsers/symbol_chunks.py +62 -0
- codebase_index/parsers/treesitter.py +439 -0
- codebase_index/retrieval/__init__.py +9 -0
- codebase_index/retrieval/budget.py +82 -0
- codebase_index/retrieval/fusion.py +62 -0
- codebase_index/retrieval/intent.py +56 -0
- codebase_index/retrieval/pipeline.py +207 -0
- codebase_index/retrieval/rerank.py +69 -0
- codebase_index/retrieval/searchers.py +291 -0
- codebase_index/retrieval/skeleton.py +251 -0
- codebase_index/retrieval/types.py +79 -0
- codebase_index/scaffold.py +399 -0
- codebase_index/service.py +158 -0
- codebase_index/skill_template/SKILL.md +198 -0
- codebase_index/skill_template/examples/hooks/settings.json +16 -0
- codebase_index/skill_template/scripts/cbx +25 -0
- codebase_index/skill_template/scripts/cbx.ps1 +25 -0
- codebase_index/skill_update.py +150 -0
- codebase_index/storage/__init__.py +8 -0
- codebase_index/storage/db.py +116 -0
- codebase_index/storage/repo.py +701 -0
- codebase_index/storage/schema.sql +125 -0
- codebase_index/watch/__init__.py +5 -0
- codebase_index/watch/watcher.py +93 -0
- codebase_index-1.6.0.dist-info/METADATA +748 -0
- codebase_index-1.6.0.dist-info/RECORD +64 -0
- codebase_index-1.6.0.dist-info/WHEEL +4 -0
- codebase_index-1.6.0.dist-info/entry_points.txt +4 -0
- codebase_index-1.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
"""Materialize the bundled skill template into project CLI trees.
|
|
2
|
+
|
|
3
|
+
Pure filesystem helpers used by the `init` CLI command. The template is read from
|
|
4
|
+
the wheel via importlib.resources, so it works in editable and zip installs alike.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
from importlib import resources
|
|
15
|
+
from importlib.resources.abc import Traversable
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
from .config import Config
|
|
20
|
+
|
|
21
|
+
CLI_TARGETS = ("claude", "codex", "opencode")
|
|
22
|
+
|
|
23
|
+
# MCP clients that receive a JSON config entry (no skill files needed).
|
|
24
|
+
MCP_TARGETS = ("cursor", "claude-desktop", "zed", "vscode", "windsurf")
|
|
25
|
+
|
|
26
|
+
ALL_TARGETS = CLI_TARGETS + MCP_TARGETS
|
|
27
|
+
|
|
28
|
+
CLAUDE_SKILL_REL = Path(".claude") / "skills" / "codebase-index"
|
|
29
|
+
CODEX_SKILL_REL = Path(".codex") / "skills" / "codebase-index"
|
|
30
|
+
OPENCODE_SKILL_REL = Path(".opencode") / "skills" / "codebase-index"
|
|
31
|
+
OPENCODE_COMMAND_REL = Path(".opencode") / "commands" / "codebase-index.md"
|
|
32
|
+
OPENCODE_AGENT_REL = Path(".opencode") / "agents" / "codebase-index.md"
|
|
33
|
+
|
|
34
|
+
SKILL_REL = CLAUDE_SKILL_REL
|
|
35
|
+
CACHE_REL = Path(".claude") / "cache" / "codebase-index"
|
|
36
|
+
_CACHE_IGNORE_LINE = ".claude/cache/codebase-index/"
|
|
37
|
+
_GITIGNORE_BLOCK = (
|
|
38
|
+
"\n# codebase-index cache (machine-local; do not commit)\n"
|
|
39
|
+
f"{_CACHE_IGNORE_LINE}\n"
|
|
40
|
+
)
|
|
41
|
+
_MANAGED_START = "<!-- >>> codebase-index managed >>> -->"
|
|
42
|
+
_MANAGED_END = "<!-- <<< codebase-index managed <<< -->"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _template_root() -> Traversable:
|
|
46
|
+
return resources.files("codebase_index") / "skill_template"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _iter_template(node: Traversable, prefix: str = "") -> "list[tuple[str, Traversable]]":
|
|
50
|
+
"""Depth-first list of (relative-posix-path, file) under a template dir."""
|
|
51
|
+
out: list[tuple[str, Traversable]] = []
|
|
52
|
+
for child in node.iterdir():
|
|
53
|
+
rel = f"{prefix}{child.name}"
|
|
54
|
+
if child.is_dir():
|
|
55
|
+
out.extend(_iter_template(child, prefix=f"{rel}/"))
|
|
56
|
+
else:
|
|
57
|
+
out.append((rel, child))
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def skill_rel_for_target(target: str) -> Path:
|
|
62
|
+
if target == "claude":
|
|
63
|
+
return CLAUDE_SKILL_REL
|
|
64
|
+
if target == "codex":
|
|
65
|
+
return CODEX_SKILL_REL
|
|
66
|
+
if target == "opencode":
|
|
67
|
+
return OPENCODE_SKILL_REL
|
|
68
|
+
raise ValueError(f"unknown CLI target: {target}")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def detect_cli_targets(root: Path) -> list[str]:
|
|
72
|
+
"""Detect usable local CLI targets for a project install."""
|
|
73
|
+
home = Path.home()
|
|
74
|
+
checks = (
|
|
75
|
+
("claude", "claude", root / ".claude", home / ".claude"),
|
|
76
|
+
("codex", "codex", root / ".codex", home / ".codex"),
|
|
77
|
+
("opencode", "opencode", root / ".opencode", home / ".config" / "opencode"),
|
|
78
|
+
)
|
|
79
|
+
return [
|
|
80
|
+
target
|
|
81
|
+
for target, command, project_marker, home_marker in checks
|
|
82
|
+
if project_marker.exists() or shutil.which(command) or home_marker.exists()
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def detect_mcp_targets(root: Path) -> list[str]:
|
|
87
|
+
"""Detect MCP-capable clients present on this machine or in this project."""
|
|
88
|
+
home = Path.home()
|
|
89
|
+
found: list[str] = []
|
|
90
|
+
|
|
91
|
+
checks: list[tuple[str, list[Optional[Path]]]] = [
|
|
92
|
+
("cursor", [root / ".cursor", home / ".cursor"]),
|
|
93
|
+
("windsurf", [root / ".windsurf", home / ".windsurf"]),
|
|
94
|
+
("vscode", [root / ".vscode"]),
|
|
95
|
+
("zed", [root / ".zed", home / ".config" / "zed"]),
|
|
96
|
+
("claude-desktop", [_claude_desktop_config_path()]),
|
|
97
|
+
]
|
|
98
|
+
exe_checks = {
|
|
99
|
+
"cursor": ["cursor"],
|
|
100
|
+
"windsurf": ["windsurf"],
|
|
101
|
+
"vscode": ["code", "code-insiders"],
|
|
102
|
+
"zed": ["zed"],
|
|
103
|
+
}
|
|
104
|
+
for target, markers in checks:
|
|
105
|
+
if any(m is not None and m.exists() for m in markers):
|
|
106
|
+
found.append(target)
|
|
107
|
+
continue
|
|
108
|
+
for exe in exe_checks.get(target, []):
|
|
109
|
+
if shutil.which(exe):
|
|
110
|
+
found.append(target)
|
|
111
|
+
break
|
|
112
|
+
return found
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def materialize_skill(root: Path, *, force: bool, target: str = "claude") -> list[Path]:
|
|
116
|
+
"""Copy the whole skill template to the target's project resource directory."""
|
|
117
|
+
dest = root / skill_rel_for_target(target)
|
|
118
|
+
if dest.exists() and not force:
|
|
119
|
+
raise FileExistsError(dest)
|
|
120
|
+
|
|
121
|
+
written: list[Path] = []
|
|
122
|
+
for rel, node in _iter_template(_template_root()):
|
|
123
|
+
dst = dest / Path(rel)
|
|
124
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
dst.write_bytes(node.read_bytes())
|
|
126
|
+
if rel == "scripts/cbx":
|
|
127
|
+
dst.chmod(dst.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
128
|
+
written.append(dst)
|
|
129
|
+
|
|
130
|
+
# Write version stamp so auto-update can detect when the skill is outdated.
|
|
131
|
+
try:
|
|
132
|
+
from importlib.metadata import version as _pkg_version
|
|
133
|
+
pkg_ver = _pkg_version("codebase-index")
|
|
134
|
+
except Exception:
|
|
135
|
+
pkg_ver = "unknown"
|
|
136
|
+
stamp = dest / ".skill_version"
|
|
137
|
+
stamp.write_text(pkg_ver + "\n", encoding="utf-8")
|
|
138
|
+
written.append(stamp)
|
|
139
|
+
|
|
140
|
+
return written
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _managed_block(content: str) -> str:
|
|
144
|
+
return f"{_MANAGED_START}\n{content.rstrip()}\n{_MANAGED_END}\n"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _upsert_managed_block(path: Path, content: str) -> Path:
|
|
148
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
149
|
+
block = _managed_block(content)
|
|
150
|
+
if _MANAGED_START in existing and _MANAGED_END in existing:
|
|
151
|
+
before, rest = existing.split(_MANAGED_START, 1)
|
|
152
|
+
_, after = rest.split(_MANAGED_END, 1)
|
|
153
|
+
new_text = before.rstrip() + "\n\n" + block + after.lstrip()
|
|
154
|
+
else:
|
|
155
|
+
sep = "" if existing in ("", "\n") else "\n\n"
|
|
156
|
+
new_text = existing.rstrip() + sep + block
|
|
157
|
+
path.write_text(new_text, encoding="utf-8")
|
|
158
|
+
return path
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def write_codex_agents(root: Path) -> Path:
|
|
162
|
+
rel = CODEX_SKILL_REL / "SKILL.md"
|
|
163
|
+
content = f"""# codebase-index
|
|
164
|
+
|
|
165
|
+
Use the local codebase index before scanning repository files.
|
|
166
|
+
|
|
167
|
+
Skill resources: `{rel.as_posix()}`
|
|
168
|
+
|
|
169
|
+
Run `codebase-index search "<query>" --json` for general questions, or use
|
|
170
|
+
`symbol`, `refs`, `impact`, and `graph` for symbol lookup, references, change
|
|
171
|
+
impact, and HTML graph export. Search/read commands auto-build the index when
|
|
172
|
+
it is missing; run `codebase-index update` when responses report stale data.
|
|
173
|
+
"""
|
|
174
|
+
return _upsert_managed_block(root / "AGENTS.md", content)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def write_opencode_files(root: Path) -> list[Path]:
|
|
178
|
+
command = root / OPENCODE_COMMAND_REL
|
|
179
|
+
agent = root / OPENCODE_AGENT_REL
|
|
180
|
+
command.parent.mkdir(parents=True, exist_ok=True)
|
|
181
|
+
agent.parent.mkdir(parents=True, exist_ok=True)
|
|
182
|
+
command.write_text(
|
|
183
|
+
"""---
|
|
184
|
+
description: Search this repository with codebase-index before reading files.
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
Run:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
codebase-index search "$ARGUMENTS" --json
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Use `symbol <name>`, `refs <name>`, or `impact <file|symbol>` when those match
|
|
194
|
+
the request. If the index is missing, run `codebase-index index` first.
|
|
195
|
+
""",
|
|
196
|
+
encoding="utf-8",
|
|
197
|
+
)
|
|
198
|
+
src = _template_root() / "SKILL.md"
|
|
199
|
+
agent.write_bytes(src.read_bytes())
|
|
200
|
+
return [command, agent]
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def install_target(root: Path, target: str, *, force: bool) -> list[Path]:
|
|
204
|
+
written = materialize_skill(root, force=force, target=target)
|
|
205
|
+
if target == "codex":
|
|
206
|
+
written.append(write_codex_agents(root))
|
|
207
|
+
elif target == "opencode":
|
|
208
|
+
written.extend(write_opencode_files(root))
|
|
209
|
+
return written
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def write_config(root: Path, *, force: bool) -> Path:
|
|
213
|
+
"""Write resolved defaults to `<root>/.claude/cache/codebase-index/config.json`."""
|
|
214
|
+
path = root / CACHE_REL / "config.json"
|
|
215
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
if path.exists() and not force:
|
|
217
|
+
return path
|
|
218
|
+
cfg = Config()
|
|
219
|
+
path.write_text(cfg.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
220
|
+
return path
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def merge_gitignore(root: Path) -> bool:
|
|
224
|
+
"""Append the cache-ignore block to `<root>/.gitignore` if absent. Returns True if changed."""
|
|
225
|
+
path = root / ".gitignore"
|
|
226
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
227
|
+
if _CACHE_IGNORE_LINE in existing:
|
|
228
|
+
return False
|
|
229
|
+
sep = "" if existing.endswith("\n") or existing == "" else "\n"
|
|
230
|
+
path.write_text(existing + sep + _GITIGNORE_BLOCK, encoding="utf-8")
|
|
231
|
+
return True
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def write_hooks_example(root: Path) -> Path:
|
|
235
|
+
"""Copy the hooks example next to the installed skill (for manual `--with-hooks` review)."""
|
|
236
|
+
src = _template_root() / "examples" / "hooks" / "settings.json"
|
|
237
|
+
path = root / SKILL_REL / "examples" / "hooks" / "settings.json"
|
|
238
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
path.write_bytes(src.read_bytes())
|
|
240
|
+
return path
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
SETTINGS_REL = Path(".claude") / "settings.json"
|
|
244
|
+
_HOOK_MARKER = "codebase-index update"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _template_hook_entries() -> "list[dict]":
|
|
248
|
+
src = _template_root() / "examples" / "hooks" / "settings.json"
|
|
249
|
+
data = json.loads(src.read_text(encoding="utf-8"))
|
|
250
|
+
return data["hooks"]["PostToolUse"]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _has_our_hook(settings: dict) -> bool:
|
|
254
|
+
for entry in settings.get("hooks", {}).get("PostToolUse", []):
|
|
255
|
+
for hk in entry.get("hooks", []):
|
|
256
|
+
if _HOOK_MARKER in hk.get("command", ""):
|
|
257
|
+
return True
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def merge_hook_settings(root: Path) -> bool:
|
|
262
|
+
path = root / SETTINGS_REL
|
|
263
|
+
settings: dict = {}
|
|
264
|
+
if path.exists():
|
|
265
|
+
settings = json.loads(path.read_text(encoding="utf-8"))
|
|
266
|
+
if _has_our_hook(settings):
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
hooks = settings.setdefault("hooks", {})
|
|
270
|
+
post = hooks.setdefault("PostToolUse", [])
|
|
271
|
+
post.extend(_template_hook_entries())
|
|
272
|
+
|
|
273
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
path.write_text(json.dumps(settings, indent=2) + "\n", encoding="utf-8")
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ── MCP client config helpers ──────────────────────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
_MCP_SERVER_NAME = "codebase-index"
|
|
281
|
+
_MCP_ENTRY_STDIO = {"command": "codebase-index", "args": ["mcp"]}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _claude_desktop_config_path() -> Optional[Path]:
|
|
285
|
+
"""Platform-specific path to Claude Desktop's config file."""
|
|
286
|
+
if sys.platform == "win32":
|
|
287
|
+
appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
|
288
|
+
return appdata / "Claude" / "claude_desktop_config.json"
|
|
289
|
+
if sys.platform == "darwin":
|
|
290
|
+
return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
|
291
|
+
return Path.home() / ".config" / "Claude" / "claude_desktop_config.json"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _load_json_file(path: Path) -> dict:
|
|
295
|
+
if path.exists():
|
|
296
|
+
try:
|
|
297
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
298
|
+
except (OSError, json.JSONDecodeError):
|
|
299
|
+
return {}
|
|
300
|
+
return {}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _write_json_file(path: Path, data: dict) -> None:
|
|
304
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _merge_mcp_servers(path: Path, entry: dict, *, force: bool) -> bool:
|
|
309
|
+
"""Merge {"mcpServers": {"codebase-index": entry}} into a JSON config file.
|
|
310
|
+
|
|
311
|
+
Returns True if the file was written (new or updated), False if already present.
|
|
312
|
+
"""
|
|
313
|
+
data = _load_json_file(path)
|
|
314
|
+
servers: dict = data.setdefault("mcpServers", {})
|
|
315
|
+
if _MCP_SERVER_NAME in servers and not force:
|
|
316
|
+
return False
|
|
317
|
+
servers[_MCP_SERVER_NAME] = entry
|
|
318
|
+
_write_json_file(path, data)
|
|
319
|
+
return True
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _merge_vscode_mcp(path: Path, *, force: bool) -> bool:
|
|
323
|
+
"""VS Code uses {"servers": {"name": {"type": "stdio", ...}}} in .vscode/mcp.json."""
|
|
324
|
+
data = _load_json_file(path)
|
|
325
|
+
servers: dict = data.setdefault("servers", {})
|
|
326
|
+
if _MCP_SERVER_NAME in servers and not force:
|
|
327
|
+
return False
|
|
328
|
+
servers[_MCP_SERVER_NAME] = {"type": "stdio", **_MCP_ENTRY_STDIO}
|
|
329
|
+
_write_json_file(path, data)
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _merge_zed_settings(path: Path, *, force: bool) -> bool:
|
|
334
|
+
"""Zed uses context_servers with a nested command object in settings.json."""
|
|
335
|
+
data = _load_json_file(path)
|
|
336
|
+
ctx: dict = data.setdefault("context_servers", {})
|
|
337
|
+
if _MCP_SERVER_NAME in ctx and not force:
|
|
338
|
+
return False
|
|
339
|
+
ctx[_MCP_SERVER_NAME] = {
|
|
340
|
+
"command": {
|
|
341
|
+
"path": "codebase-index",
|
|
342
|
+
"args": ["mcp"],
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
_write_json_file(path, data)
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def install_mcp_target(root: Path, target: str, *, force: bool = False) -> tuple[Path, bool]:
|
|
350
|
+
"""Write or merge the MCP server entry for `target`.
|
|
351
|
+
|
|
352
|
+
Returns (config_path, written) where written=False means it was already present.
|
|
353
|
+
Raises ValueError for unknown targets.
|
|
354
|
+
"""
|
|
355
|
+
if target == "cursor":
|
|
356
|
+
path = root / ".cursor" / "mcp.json"
|
|
357
|
+
written = _merge_mcp_servers(path, _MCP_ENTRY_STDIO, force=force)
|
|
358
|
+
return path, written
|
|
359
|
+
|
|
360
|
+
if target == "windsurf":
|
|
361
|
+
path = root / ".windsurf" / "mcp.json"
|
|
362
|
+
written = _merge_mcp_servers(path, _MCP_ENTRY_STDIO, force=force)
|
|
363
|
+
return path, written
|
|
364
|
+
|
|
365
|
+
if target == "vscode":
|
|
366
|
+
path = root / ".vscode" / "mcp.json"
|
|
367
|
+
written = _merge_vscode_mcp(path, force=force)
|
|
368
|
+
return path, written
|
|
369
|
+
|
|
370
|
+
if target == "zed":
|
|
371
|
+
# prefer project-local; Zed picks it up automatically
|
|
372
|
+
path = root / ".zed" / "settings.json"
|
|
373
|
+
written = _merge_zed_settings(path, force=force)
|
|
374
|
+
return path, written
|
|
375
|
+
|
|
376
|
+
if target == "claude-desktop":
|
|
377
|
+
maybe_path = _claude_desktop_config_path()
|
|
378
|
+
if maybe_path is None:
|
|
379
|
+
raise RuntimeError("Cannot determine Claude Desktop config path on this platform.")
|
|
380
|
+
written = _merge_mcp_servers(maybe_path, _MCP_ENTRY_STDIO, force=force)
|
|
381
|
+
return maybe_path, written
|
|
382
|
+
|
|
383
|
+
raise ValueError(f"unknown MCP target: {target!r}. Valid: {MCP_TARGETS}")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def enabled_hooks(root: Path) -> list[str]:
|
|
387
|
+
path = root / SETTINGS_REL
|
|
388
|
+
if not path.exists():
|
|
389
|
+
return []
|
|
390
|
+
try:
|
|
391
|
+
settings = json.loads(path.read_text(encoding="utf-8"))
|
|
392
|
+
except (OSError, json.JSONDecodeError):
|
|
393
|
+
return []
|
|
394
|
+
return [
|
|
395
|
+
hk.get("command", "")
|
|
396
|
+
for entry in settings.get("hooks", {}).get("PostToolUse", [])
|
|
397
|
+
for hk in entry.get("hooks", [])
|
|
398
|
+
if _HOOK_MARKER in hk.get("command", "")
|
|
399
|
+
]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Shared service layer for the CLI and the MCP server.
|
|
2
|
+
|
|
3
|
+
Both surfaces drive the same retrieval/storage code; this module owns the
|
|
4
|
+
pieces that used to be duplicated and drift apart: the cache-path formula,
|
|
5
|
+
db/config resolution, the explain query rewrite, vector-aware search
|
|
6
|
+
sessions, and the stats payload (including the per-language graph tier the
|
|
7
|
+
skill keys on).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional, Union
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .config import Config
|
|
19
|
+
|
|
20
|
+
_EXPLAIN_HINTS = ("how", "architecture", "overview")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def cache_dir_for(cfg: "Config") -> Path:
|
|
24
|
+
"""Per-project cache directory (index DB, graph exports, skill backups)."""
|
|
25
|
+
return Path(cfg.root) / ".claude" / "cache" / "codebase-index"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def db_path_for(cfg: "Config") -> Path:
|
|
29
|
+
"""Index location for a resolved config; the CBX_DB_PATH env var overrides."""
|
|
30
|
+
override = os.environ.get("CBX_DB_PATH")
|
|
31
|
+
if override:
|
|
32
|
+
return Path(override)
|
|
33
|
+
return cache_dir_for(cfg) / "index.sqlite"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_db(root: Optional[Union[Path, str]] = None) -> tuple[Path, "Config"]:
|
|
37
|
+
"""Resolve (db_path, config) the same way on every surface.
|
|
38
|
+
|
|
39
|
+
The config loads from *root* (CLI --root, MCP CBX_ROOT, else upward
|
|
40
|
+
discovery from cwd); CBX_DB_PATH overrides only the index location.
|
|
41
|
+
"""
|
|
42
|
+
from .config import load
|
|
43
|
+
|
|
44
|
+
cfg = load(Path(root) if root is not None else None)
|
|
45
|
+
return db_path_for(cfg), cfg
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def search_backend(cfg: "Config", warn: Callable[[str], None]) -> Any:
|
|
49
|
+
"""Embedding backend for query-time vector search.
|
|
50
|
+
|
|
51
|
+
Returns a NoopBackend (enabled=False) when embeddings are off, so callers
|
|
52
|
+
can branch on `backend.enabled`. Network/external gating is enforced by
|
|
53
|
+
resolve_backend (SECURITY.md §4).
|
|
54
|
+
"""
|
|
55
|
+
from .embeddings.backend import resolve_backend
|
|
56
|
+
|
|
57
|
+
return resolve_backend(cfg, warn=warn)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def normalize_explain_query(query: str) -> str:
|
|
61
|
+
"""Rewrite a bare topic into a how-does-X-work question for intent detection."""
|
|
62
|
+
if any(w in query.lower() for w in _EXPLAIN_HINTS):
|
|
63
|
+
return query
|
|
64
|
+
return f"how does {query} work"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def search_payload(
|
|
68
|
+
db_path: Path,
|
|
69
|
+
cfg: "Config",
|
|
70
|
+
query: str,
|
|
71
|
+
*,
|
|
72
|
+
mode: str = "hybrid",
|
|
73
|
+
limit: int = 10,
|
|
74
|
+
offset: int = 0,
|
|
75
|
+
token_budget: int = 1500,
|
|
76
|
+
no_fallback: bool = False,
|
|
77
|
+
backend: Any = None,
|
|
78
|
+
raw: bool = False,
|
|
79
|
+
) -> dict:
|
|
80
|
+
"""One search session: open the DB (vector-enabled when the backend is
|
|
81
|
+
live), run retrieval, return the payload dict both surfaces serialize.
|
|
82
|
+
|
|
83
|
+
``raw`` forces full snippets; otherwise snippets are skeletonized when
|
|
84
|
+
``cfg.retrieval.compact_snippets`` is on (the default)."""
|
|
85
|
+
from .retrieval.pipeline import search as run_search
|
|
86
|
+
from .storage.db import Database
|
|
87
|
+
|
|
88
|
+
compact = cfg.retrieval.compact_snippets and not raw
|
|
89
|
+
with Database(db_path) as db:
|
|
90
|
+
if backend is not None and getattr(backend, "enabled", False):
|
|
91
|
+
db.enable_vectors()
|
|
92
|
+
return run_search(
|
|
93
|
+
db.conn,
|
|
94
|
+
query,
|
|
95
|
+
mode=mode,
|
|
96
|
+
limit=limit,
|
|
97
|
+
offset=offset,
|
|
98
|
+
token_budget=token_budget,
|
|
99
|
+
no_fallback=no_fallback,
|
|
100
|
+
backend=backend,
|
|
101
|
+
root=Path(cfg.root),
|
|
102
|
+
config=cfg,
|
|
103
|
+
compact=compact,
|
|
104
|
+
compact_min_reduction=cfg.retrieval.compact_min_reduction,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def architecture_payload(db_path: Path, cfg: "Config") -> dict[str, Any]:
|
|
109
|
+
"""The cached architecture analytics (communities / god nodes / surprising /
|
|
110
|
+
questions) plus index freshness — the payload both CLI and MCP serialize.
|
|
111
|
+
|
|
112
|
+
Returns ``available: False`` when no analysis is cached (an index built before
|
|
113
|
+
this feature, or an empty graph); the caller tells the user to reindex.
|
|
114
|
+
"""
|
|
115
|
+
from .graph import analysis
|
|
116
|
+
from .indexer.freshness import compute_freshness
|
|
117
|
+
from .storage.db import Database
|
|
118
|
+
|
|
119
|
+
with Database(db_path) as db:
|
|
120
|
+
fresh = compute_freshness(db.conn, Path(cfg.root), cfg)
|
|
121
|
+
summary = analysis.load_analysis(db.conn)
|
|
122
|
+
if summary is None:
|
|
123
|
+
return {
|
|
124
|
+
"exists": True,
|
|
125
|
+
"available": False,
|
|
126
|
+
"reason": (
|
|
127
|
+
"No architecture analysis cached. Rebuild the index "
|
|
128
|
+
"(`codebase-index index`) to compute it."
|
|
129
|
+
),
|
|
130
|
+
"index": fresh.model_dump(),
|
|
131
|
+
}
|
|
132
|
+
return {"exists": True, "available": True, "index": fresh.model_dump(), **summary}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def stats_payload(conn: sqlite3.Connection) -> dict[str, Any]:
|
|
136
|
+
"""Index size, freshness, and per-language coverage with the graph tier."""
|
|
137
|
+
from .parsers.languages import has_full_graph
|
|
138
|
+
from .storage import repo
|
|
139
|
+
|
|
140
|
+
coverage = [
|
|
141
|
+
{
|
|
142
|
+
"lang": r["lang"],
|
|
143
|
+
"files": r["files"],
|
|
144
|
+
"symbols": r["symbols"],
|
|
145
|
+
# Tier-A languages get import/inheritance edges; Tier-B is
|
|
146
|
+
# symbols-only, so refs/impact are partial for them.
|
|
147
|
+
"graph": "full" if has_full_graph(r["lang"]) else "partial",
|
|
148
|
+
}
|
|
149
|
+
for r in repo.treesitter_coverage(conn)
|
|
150
|
+
]
|
|
151
|
+
return {
|
|
152
|
+
"files": repo.count_files(conn),
|
|
153
|
+
"symbols": repo.count_symbols(conn),
|
|
154
|
+
"built_at": repo.get_meta(conn, "built_at"),
|
|
155
|
+
"head_commit": repo.get_meta(conn, "head_commit"),
|
|
156
|
+
"treesitter_coverage": coverage,
|
|
157
|
+
"exists": True,
|
|
158
|
+
}
|