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.
Files changed (64) hide show
  1. codebase_index/__init__.py +7 -0
  2. codebase_index/__main__.py +3 -0
  3. codebase_index/cli.py +916 -0
  4. codebase_index/config.py +110 -0
  5. codebase_index/discovery/__init__.py +10 -0
  6. codebase_index/discovery/classify.py +151 -0
  7. codebase_index/discovery/ignore.py +58 -0
  8. codebase_index/discovery/walker.py +75 -0
  9. codebase_index/doctor.py +138 -0
  10. codebase_index/embeddings/__init__.py +2 -0
  11. codebase_index/embeddings/backend.py +67 -0
  12. codebase_index/embeddings/external.py +56 -0
  13. codebase_index/embeddings/local.py +41 -0
  14. codebase_index/embeddings/noop.py +15 -0
  15. codebase_index/graph/__init__.py +8 -0
  16. codebase_index/graph/analysis.py +468 -0
  17. codebase_index/graph/builder.py +160 -0
  18. codebase_index/graph/expand.py +136 -0
  19. codebase_index/graph/export.py +381 -0
  20. codebase_index/graph/navigate.py +201 -0
  21. codebase_index/indexer/__init__.py +8 -0
  22. codebase_index/indexer/doc_chunks.py +202 -0
  23. codebase_index/indexer/freshness.py +109 -0
  24. codebase_index/indexer/pipeline.py +423 -0
  25. codebase_index/mcp/__init__.py +2 -0
  26. codebase_index/mcp/server.py +354 -0
  27. codebase_index/models.py +145 -0
  28. codebase_index/output/__init__.py +6 -0
  29. codebase_index/output/json.py +13 -0
  30. codebase_index/output/markdown.py +316 -0
  31. codebase_index/output/redact.py +31 -0
  32. codebase_index/parsers/__init__.py +9 -0
  33. codebase_index/parsers/base.py +47 -0
  34. codebase_index/parsers/languages.py +290 -0
  35. codebase_index/parsers/line_chunker.py +39 -0
  36. codebase_index/parsers/symbol_chunks.py +62 -0
  37. codebase_index/parsers/treesitter.py +439 -0
  38. codebase_index/retrieval/__init__.py +9 -0
  39. codebase_index/retrieval/budget.py +82 -0
  40. codebase_index/retrieval/fusion.py +62 -0
  41. codebase_index/retrieval/intent.py +56 -0
  42. codebase_index/retrieval/pipeline.py +207 -0
  43. codebase_index/retrieval/rerank.py +69 -0
  44. codebase_index/retrieval/searchers.py +291 -0
  45. codebase_index/retrieval/skeleton.py +251 -0
  46. codebase_index/retrieval/types.py +79 -0
  47. codebase_index/scaffold.py +399 -0
  48. codebase_index/service.py +158 -0
  49. codebase_index/skill_template/SKILL.md +198 -0
  50. codebase_index/skill_template/examples/hooks/settings.json +16 -0
  51. codebase_index/skill_template/scripts/cbx +25 -0
  52. codebase_index/skill_template/scripts/cbx.ps1 +25 -0
  53. codebase_index/skill_update.py +150 -0
  54. codebase_index/storage/__init__.py +8 -0
  55. codebase_index/storage/db.py +116 -0
  56. codebase_index/storage/repo.py +701 -0
  57. codebase_index/storage/schema.sql +125 -0
  58. codebase_index/watch/__init__.py +5 -0
  59. codebase_index/watch/watcher.py +93 -0
  60. codebase_index-1.6.0.dist-info/METADATA +748 -0
  61. codebase_index-1.6.0.dist-info/RECORD +64 -0
  62. codebase_index-1.6.0.dist-info/WHEEL +4 -0
  63. codebase_index-1.6.0.dist-info/entry_points.txt +4 -0
  64. codebase_index-1.6.0.dist-info/licenses/LICENSE +21 -0
codebase_index/cli.py ADDED
@@ -0,0 +1,916 @@
1
+ """Typer CLI app — the single entry point for both humans and the Claude Code skill.
2
+
3
+ Commands map 1:1 to docs/ARCHITECTURE.md §5 (CLI contract) and delegate to the
4
+ `indexer`, `retrieval`, and `storage` layers through `service.py` — the same
5
+ layer the MCP server uses, so the two surfaces cannot drift. Only `clean` is
6
+ still a stub.
7
+
8
+ Conventions:
9
+ * every command accepts global options via the Typer context: --root, --json, --quiet
10
+ * read-only/search commands accept --limit, --token-budget
11
+ * output goes through `output.json` (when --json) or `output.markdown` (otherwise)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ import webbrowser
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ # Force UTF-8 output on Windows to avoid cp1251 encoding errors
24
+ if sys.platform == "win32":
25
+ sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
26
+ sys.stderr.reconfigure(encoding="utf-8") # type: ignore[union-attr]
27
+
28
+ import typer
29
+
30
+ app = typer.Typer(
31
+ name="codebase-index",
32
+ help="Local-first hybrid codebase index for Claude Code (Skill + CLI).",
33
+ no_args_is_help=True,
34
+ add_completion=False,
35
+ )
36
+
37
+
38
+ # --- global state resolved from common options --------------------------------------------------
39
+ def _ensure_index(ctx: "typer.Context") -> tuple[Path, Any]:
40
+ from .indexer.pipeline import build_index
41
+ from .service import resolve_db
42
+ from .storage.db import Database
43
+
44
+ root_opt = ctx.obj.get("root") if ctx.obj else None
45
+ db_path, cfg = resolve_db(root_opt)
46
+ if db_path.exists():
47
+ return db_path, cfg
48
+
49
+ if not (ctx.obj and (ctx.obj.get("quiet") or ctx.obj.get("json"))):
50
+ typer.echo("[codebase-index] no index found; building one now.", err=True)
51
+ db_path.parent.mkdir(parents=True, exist_ok=True)
52
+ with Database(db_path) as db:
53
+ build_index(cfg, db, root=Path(cfg.root))
54
+ return db_path, cfg
55
+
56
+
57
+ def _remove_db_files(db_path: Path) -> None:
58
+ """Delete the SQLite db and its WAL/SHM sidecars (used to force a clean rebuild)."""
59
+ for p in (db_path, *(db_path.with_name(db_path.name + s) for s in ("-wal", "-shm"))):
60
+ if p.exists():
61
+ p.unlink()
62
+
63
+
64
+ def _open_in_browser(path: Path) -> None:
65
+ uri = path.resolve().as_uri()
66
+ try:
67
+ webbrowser.open(uri)
68
+ return
69
+ except Exception:
70
+ pass
71
+ if sys.platform == "win32":
72
+ subprocess.Popen(["cmd", "/c", "start", "", uri], shell=False)
73
+ elif sys.platform == "darwin":
74
+ subprocess.Popen(["open", uri])
75
+ else:
76
+ subprocess.Popen(["xdg-open", uri])
77
+
78
+
79
+ def _resolve_backend_for_search(ctx: "typer.Context"):
80
+ """Embedding backend for query-time vector search (see service.search_backend)."""
81
+ from .config import load
82
+ from .service import search_backend
83
+
84
+ cfg = load(ctx.obj.get("root") if ctx.obj else None)
85
+ return search_backend(cfg, warn=lambda m: typer.echo(m, err=True))
86
+
87
+
88
+ def _interactive_target_choice(detected_cli: list[str], detected_mcp: list[str]) -> str:
89
+ from rich.console import Console
90
+ from rich.prompt import Prompt
91
+ from rich.table import Table
92
+
93
+ from . import scaffold
94
+
95
+ console = Console()
96
+ table = Table(title="Install codebase-index")
97
+ table.add_column("#", justify="right")
98
+ table.add_column("Target")
99
+ table.add_column("Type")
100
+ table.add_column("Status")
101
+
102
+ rows: list[str] = [*scaffold.CLI_TARGETS, *scaffold.MCP_TARGETS, "all"]
103
+ for idx, name in enumerate(rows, start=1):
104
+ kind = "skill" if name in scaffold.CLI_TARGETS else ("MCP" if name in scaffold.MCP_TARGETS else "")
105
+ status = "detected" if name in detected_cli or name in detected_mcp else ""
106
+ if name == "all":
107
+ kind = ""
108
+ status = "install everything"
109
+ table.add_row(str(idx), name, kind, status)
110
+ console.print(table)
111
+
112
+ all_detected = detected_cli + [t for t in detected_mcp if t not in detected_cli]
113
+ default = all_detected[0] if len(all_detected) == 1 else "all" if all_detected else "claude"
114
+ choices = [*rows, *[str(i) for i in range(1, len(rows) + 1)]]
115
+ selected = Prompt.ask("Choose target", choices=choices, default=default)
116
+ if selected.isdigit():
117
+ return rows[int(selected) - 1]
118
+ return selected
119
+
120
+
121
+ def _resolve_init_targets(root: Path, requested: str | None) -> tuple[list[str], list[str]]:
122
+ """Returns (cli_targets, mcp_targets)."""
123
+ from . import scaffold
124
+
125
+ detected_cli = scaffold.detect_cli_targets(root)
126
+ detected_mcp = scaffold.detect_mcp_targets(root)
127
+
128
+ if requested is None:
129
+ if sys.stdin.isatty():
130
+ requested = _interactive_target_choice(detected_cli, detected_mcp)
131
+ else:
132
+ requested = "claude"
133
+
134
+ if requested == "auto":
135
+ all_detected = detected_cli + [t for t in detected_mcp if t not in detected_cli]
136
+ if not all_detected:
137
+ typer.echo(
138
+ "[codebase-index] no targets detected. "
139
+ f"Use --target with one of: {', '.join(scaffold.ALL_TARGETS)}, all.",
140
+ err=True,
141
+ )
142
+ raise typer.Exit(code=4)
143
+ typer.echo(f"Detected targets: {', '.join(all_detected)}")
144
+ return (
145
+ [t for t in all_detected if t in scaffold.CLI_TARGETS],
146
+ [t for t in all_detected if t in scaffold.MCP_TARGETS],
147
+ )
148
+
149
+ if requested == "all":
150
+ return list(scaffold.CLI_TARGETS), list(scaffold.MCP_TARGETS)
151
+
152
+ if requested in scaffold.CLI_TARGETS:
153
+ return [requested], []
154
+
155
+ if requested in scaffold.MCP_TARGETS:
156
+ return [], [requested]
157
+
158
+ typer.echo(
159
+ f"[codebase-index] invalid target '{requested}'. "
160
+ f"Valid: {', '.join(scaffold.ALL_TARGETS)}, auto, all.",
161
+ err=True,
162
+ )
163
+ raise typer.Exit(code=2)
164
+
165
+
166
+ def _try_auto_update_skills(root_opt: Optional[Path]) -> None:
167
+ """Silently update all installed skills when the package version changed."""
168
+ if os.environ.get("CBX_NO_SKILL_AUTO_UPDATE") == "1":
169
+ return
170
+ try:
171
+ from .config import find_root
172
+ from . import scaffold
173
+ from .skill_update import auto_update_if_needed
174
+
175
+ root = Path(root_opt).resolve() if root_opt else find_root()
176
+ for target in scaffold.CLI_TARGETS:
177
+ auto_update_if_needed(root, target)
178
+ except Exception as exc:
179
+ # Never let an auto-update failure crash the real command — but say so.
180
+ typer.echo(f"[codebase-index] skill auto-update skipped: {exc}", err=True)
181
+
182
+
183
+ @app.callback()
184
+ def main(
185
+ ctx: typer.Context,
186
+ root: Optional[Path] = typer.Option(None, "--root", help="Project root (default: discover from cwd)."),
187
+ json_out: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
188
+ quiet: bool = typer.Option(False, "--quiet", help="Suppress progress output."),
189
+ ) -> None:
190
+ ctx.obj = {"root": root, "json": json_out, "quiet": quiet}
191
+ _try_auto_update_skills(root)
192
+
193
+
194
+ # --- lifecycle ----------------------------------------------------------------------------------
195
+ @app.command()
196
+ def init(
197
+ ctx: typer.Context,
198
+ force: bool = typer.Option(False, "--force", help="Overwrite an existing install."),
199
+ with_hooks: bool = typer.Option(
200
+ False,
201
+ "--with-hooks/--no-hooks",
202
+ help="Also write and merge the Claude Code auto-update hook.",
203
+ ),
204
+ target: Optional[str] = typer.Option(
205
+ None,
206
+ "--target",
207
+ help=(
208
+ "Target to install: claude, codex, opencode (skill-based) | "
209
+ "cursor, claude-desktop, zed, vscode, windsurf (MCP config) | "
210
+ "auto (detect) | all. Prompts when interactive."
211
+ ),
212
+ ),
213
+ ) -> None:
214
+ """Scaffold skill/MCP config, config.json, and .gitignore rules into the current project."""
215
+ from . import scaffold
216
+ from .config import find_root
217
+
218
+ root_opt = ctx.obj.get("root") if ctx.obj else None
219
+ root = Path(root_opt).resolve() if root_opt else find_root()
220
+ cli_targets, mcp_targets = _resolve_init_targets(root, target)
221
+
222
+ lines: list[str] = []
223
+
224
+ # Install skill targets (claude / codex / opencode)
225
+ for name in cli_targets:
226
+ try:
227
+ scaffold.install_target(root, name, force=force)
228
+ except FileExistsError as exc:
229
+ typer.echo(
230
+ f"[codebase-index] '{name}' already installed at {exc.args[0]}. "
231
+ "Re-run with --force to overwrite."
232
+ )
233
+ raise typer.Exit(code=1)
234
+ lines.append(f"Installed {name:<14} (skill) -> {root / scaffold.skill_rel_for_target(name)}")
235
+
236
+ # Install MCP config targets
237
+ for name in mcp_targets:
238
+ try:
239
+ cfg_file, written = scaffold.install_mcp_target(root, name, force=force)
240
+ except RuntimeError as exc:
241
+ typer.echo(f"[codebase-index] {name}: {exc}", err=True)
242
+ continue
243
+ state = "written" if written else "already present"
244
+ lines.append(f"Installed {name:<14} (MCP) -> {cfg_file} [{state}]")
245
+
246
+ cfg_path = scaffold.write_config(root, force=force)
247
+ gitignore_changed = scaffold.merge_gitignore(root)
248
+
249
+ lines += [
250
+ f"Wrote config -> {cfg_path}",
251
+ f".gitignore -> {'updated' if gitignore_changed else 'already covered'}",
252
+ ]
253
+
254
+ if with_hooks:
255
+ if "claude" not in cli_targets:
256
+ lines.append("Auto-update hook -> skipped (hooks are Claude Code settings)")
257
+ else:
258
+ hook_path = scaffold.write_hooks_example(root)
259
+ merged = scaffold.merge_hook_settings(root)
260
+ state = "enabled in .claude/settings.json" if merged else "already enabled"
261
+ lines.append(f"Auto-update hook -> {state}")
262
+ lines.append(f"Hook example -> {hook_path} (reference copy)")
263
+
264
+ has_mcp = bool(mcp_targets)
265
+ lines += [
266
+ "",
267
+ "Next steps:",
268
+ " 1. codebase-index index # build the index",
269
+ " 2. codebase-index stats # verify coverage",
270
+ ]
271
+ if has_mcp:
272
+ lines.append(" 3. Restart your editor — the MCP server will be discovered automatically.")
273
+ else:
274
+ lines.append(" 3. Ask a codebase question in your CLI — the installed instructions will invoke it.")
275
+ typer.echo("\n".join(lines))
276
+
277
+
278
+ @app.command()
279
+ def index(
280
+ ctx: typer.Context,
281
+ rebuild: bool = typer.Option(False, "--rebuild", help="Discard and rebuild from scratch."),
282
+ ) -> None:
283
+ """Full index build into .claude/cache/codebase-index/index.sqlite."""
284
+ import json as _json
285
+
286
+ from .config import load
287
+ from .indexer.pipeline import build_index
288
+ from .storage.db import SCHEMA_VERSION, Database, peek_schema_version
289
+
290
+ root_opt = ctx.obj.get("root") if ctx.obj else None
291
+ cfg = load(root_opt)
292
+ db_path = Path(cfg.root) / ".claude" / "cache" / "codebase-index" / "index.sqlite"
293
+ # A full build discards an outdated-schema index: schema.sql is applied with
294
+ # IF NOT EXISTS, so an upgrade can't add columns/triggers in place — recreate.
295
+ if rebuild or (db_path.exists() and peek_schema_version(db_path) < SCHEMA_VERSION):
296
+ _remove_db_files(db_path)
297
+
298
+ with Database(db_path) as db:
299
+ stats = build_index(cfg, db, root=Path(cfg.root))
300
+
301
+ if ctx.obj and ctx.obj.get("json"):
302
+ typer.echo(
303
+ _json.dumps(
304
+ {
305
+ "indexed": stats.indexed,
306
+ "deleted": stats.deleted,
307
+ "total_bytes": stats.total_bytes,
308
+ "symbols": stats.symbols,
309
+ "parse_failed": stats.parse_failed,
310
+ "treesitter_zero_symbols": stats.treesitter_zero_symbols,
311
+ }
312
+ )
313
+ )
314
+ elif not (ctx.obj and ctx.obj.get("quiet")):
315
+ typer.echo(f"Indexed {stats.indexed} files ({stats.deleted} pruned).")
316
+ if stats.parse_failed or stats.treesitter_zero_symbols:
317
+ typer.echo(
318
+ f" parse failures: {stats.parse_failed}; "
319
+ f"tree-sitter files with 0 symbols: {stats.treesitter_zero_symbols}"
320
+ )
321
+
322
+
323
+ @app.command()
324
+ def update(
325
+ ctx: typer.Context,
326
+ since: Optional[str] = typer.Option(None, "--since", help="Re-index files changed since a git ref."),
327
+ all_files: bool = typer.Option(False, "--all", help="Force re-check (hash) of every file."),
328
+ ) -> None:
329
+ """Incremental re-index (mtime/sha/git aware). Safe to call from a hook or watcher."""
330
+ import json as _json
331
+
332
+ from .config import load
333
+ from .indexer.pipeline import build_index, update_index
334
+ from .storage.db import SCHEMA_VERSION, Database, peek_schema_version
335
+
336
+ is_json = bool(ctx.obj and ctx.obj.get("json"))
337
+ quiet = bool(ctx.obj and ctx.obj.get("quiet"))
338
+
339
+ cfg = load(ctx.obj.get("root") if ctx.obj else None)
340
+ db_path = Path(cfg.root) / ".claude" / "cache" / "codebase-index" / "index.sqlite"
341
+ if not db_path.exists():
342
+ if is_json:
343
+ typer.echo(_json.dumps({"indexed": 0, "deleted": 0, "skipped": 0, "exists": False}))
344
+ elif not quiet:
345
+ typer.echo("No index found. Run `codebase-index index` first.")
346
+ raise typer.Exit(code=0)
347
+
348
+ if peek_schema_version(db_path) < SCHEMA_VERSION:
349
+ # Schema changed under the index; an incremental write would target old
350
+ # tables. Upgrade by rebuilding from scratch (the index is a derived cache).
351
+ _remove_db_files(db_path)
352
+ with Database(db_path) as db:
353
+ stats = build_index(cfg, db, root=Path(cfg.root))
354
+ else:
355
+ with Database(db_path) as db:
356
+ stats = update_index(cfg, db, root=Path(cfg.root), since=since, all_files=all_files)
357
+
358
+ if is_json:
359
+ typer.echo(
360
+ _json.dumps(
361
+ {"indexed": stats.indexed, "deleted": stats.deleted, "skipped": stats.skipped}
362
+ )
363
+ )
364
+ elif not quiet:
365
+ typer.echo(
366
+ f"Updated {stats.indexed} file(s); {stats.deleted} pruned; {stats.skipped} unchanged."
367
+ )
368
+
369
+
370
+ # --- retrieval (read-only; these are what the skill calls) --------------------------------------
371
+ @app.command()
372
+ def search(
373
+ ctx: typer.Context,
374
+ query: str = typer.Argument(..., help="Search query."),
375
+ limit: int = typer.Option(10, "--limit"),
376
+ offset: int = typer.Option(
377
+ 0, "--offset", help="Skip the first N results (use pagination.next_offset to page)."
378
+ ),
379
+ token_budget: int = typer.Option(1500, "--token-budget"),
380
+ mode: str = typer.Option("hybrid", "--mode", help="hybrid|fts|symbol|vector"),
381
+ no_fallback: bool = typer.Option(False, "--no-fallback"),
382
+ raw: bool = typer.Option(
383
+ False, "--raw",
384
+ help="Disable snippet skeletonization; return full raw snippets.",
385
+ ),
386
+ json_out: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
387
+ ) -> None:
388
+ """Hybrid ranked search; returns compact results + recommended_reads."""
389
+ from .output import json as json_renderer
390
+ from .output import markdown as md_renderer
391
+ from .service import search_payload
392
+
393
+ if offset < 0:
394
+ typer.echo("[codebase-index] --offset must be >= 0.")
395
+ raise typer.Exit(code=2)
396
+
397
+ backend = None
398
+ if mode in ("vector", "hybrid"):
399
+ backend = _resolve_backend_for_search(ctx)
400
+ if mode == "vector" and not getattr(backend, "enabled", False):
401
+ typer.echo(
402
+ "[codebase-index] vector mode needs embeddings.enabled = true and the "
403
+ "[embeddings] extra. Use --mode hybrid or enable embeddings."
404
+ )
405
+ raise typer.Exit(code=2)
406
+
407
+ db_path, cfg = _ensure_index(ctx)
408
+ payload = search_payload(
409
+ db_path, cfg, query, mode=mode, limit=limit, offset=offset,
410
+ token_budget=token_budget, no_fallback=no_fallback, backend=backend, raw=raw,
411
+ )
412
+
413
+ want_json = json_out or (ctx.obj and ctx.obj.get("json"))
414
+ typer.echo(json_renderer.render(payload) if want_json else md_renderer.render(payload))
415
+
416
+
417
+ @app.command()
418
+ def symbol(
419
+ ctx: typer.Context,
420
+ name: str = typer.Argument(...),
421
+ kind: Optional[str] = typer.Option(None, "--kind", help="Filter by symbol kind."),
422
+ exact: bool = typer.Option(False, "--exact"),
423
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
424
+ ) -> None:
425
+ """Locate a symbol definition by name."""
426
+ from .output import json as json_out
427
+ from .output import markdown as md_out
428
+ from .retrieval.searchers import symbol_lookup
429
+ from .storage.db import Database
430
+
431
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
432
+ db_path, _cfg = _ensure_index(ctx)
433
+
434
+ with Database(db_path) as db:
435
+ resp = symbol_lookup(db.conn, name, kind=kind, exact=exact)
436
+ typer.echo(json_out.render(resp) if is_json else md_out.render_symbols(resp))
437
+
438
+
439
+ @app.command()
440
+ def refs(
441
+ ctx: typer.Context,
442
+ symbol_name: str = typer.Argument(...),
443
+ kind: str = typer.Option("all", "--kind", help="callers|all"),
444
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
445
+ ) -> None:
446
+ """Find references / callers of a symbol."""
447
+ from .output import json as json_out
448
+ from .output import markdown as md_out
449
+ from .retrieval.searchers import refs_lookup
450
+ from .storage.db import Database
451
+
452
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
453
+ db_path, _cfg = _ensure_index(ctx)
454
+
455
+ with Database(db_path) as db:
456
+ resp = refs_lookup(db.conn, symbol_name, kind=kind)
457
+ typer.echo(json_out.render(resp) if is_json else md_out.render_refs(resp))
458
+
459
+
460
+ @app.command()
461
+ def impact(
462
+ ctx: typer.Context,
463
+ target: str = typer.Argument(..., help="File path or symbol name."),
464
+ depth: int = typer.Option(2, "--depth"),
465
+ direction: str = typer.Option("up", "--direction", help="up|down|both"),
466
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
467
+ ) -> None:
468
+ """Blast radius: what is affected if `target` changes (graph walk)."""
469
+ from .graph.expand import impact_lookup
470
+ from .output import json as json_out
471
+ from .output import markdown as md_out
472
+ from .storage.db import Database
473
+
474
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
475
+ db_path, _cfg = _ensure_index(ctx)
476
+
477
+ with Database(db_path) as db:
478
+ resp = impact_lookup(db.conn, target, depth=depth, direction=direction)
479
+ typer.echo(json_out.render(resp) if is_json else md_out.render_impact(resp))
480
+
481
+
482
+ @app.command()
483
+ def explain(
484
+ ctx: typer.Context,
485
+ query: str = typer.Argument(...),
486
+ token_budget: int = typer.Option(2200, "--token-budget"),
487
+ raw: bool = typer.Option(
488
+ False, "--raw",
489
+ help="Disable snippet skeletonization; return full raw snippets.",
490
+ ),
491
+ json_out: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
492
+ ) -> None:
493
+ """Intent-aware bundle for 'how does X work' / overview questions."""
494
+ from .output import json as json_renderer
495
+ from .output import markdown as md_renderer
496
+ from .service import normalize_explain_query, search_payload
497
+
498
+ backend = _resolve_backend_for_search(ctx)
499
+ db_path, cfg = _ensure_index(ctx)
500
+
501
+ payload = search_payload(
502
+ db_path, cfg, normalize_explain_query(query), mode="hybrid", limit=10,
503
+ token_budget=token_budget, no_fallback=False, backend=backend, raw=raw,
504
+ )
505
+
506
+ want_json = json_out or (ctx.obj and ctx.obj.get("json"))
507
+ typer.echo(json_renderer.render(payload) if want_json else md_renderer.render(payload))
508
+
509
+
510
+ @app.command("architecture")
511
+ def architecture(
512
+ ctx: typer.Context,
513
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
514
+ ) -> None:
515
+ """High-level map of the codebase: modules, god nodes, surprising links, questions.
516
+
517
+ Reads the analytics cached at index time (no recompute). Rebuild the index if it
518
+ reports no analysis available.
519
+ """
520
+ from .output import json as json_renderer
521
+ from .output import markdown as md_renderer
522
+ from .service import architecture_payload
523
+
524
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
525
+ db_path, cfg = _ensure_index(ctx)
526
+ payload = architecture_payload(db_path, cfg)
527
+ typer.echo(
528
+ json_renderer.render(payload) if is_json else md_renderer.render_architecture(payload)
529
+ )
530
+
531
+
532
+ @app.command("path")
533
+ def path_between(
534
+ ctx: typer.Context,
535
+ source: str = typer.Argument(..., help="File path or symbol name to start from."),
536
+ target: str = typer.Argument(..., help="File path or symbol name to reach."),
537
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
538
+ ) -> None:
539
+ """Shortest dependency/call path between two symbols or files (how are they connected)."""
540
+ from .graph.navigate import path_payload
541
+ from .output import json as json_renderer
542
+ from .output import markdown as md_renderer
543
+ from .storage.db import Database
544
+
545
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
546
+ db_path, _cfg = _ensure_index(ctx)
547
+ with Database(db_path) as db:
548
+ payload = path_payload(db.conn, source, target)
549
+ typer.echo(json_renderer.render(payload) if is_json else md_renderer.render_path(payload))
550
+
551
+
552
+ @app.command("describe")
553
+ def describe(
554
+ ctx: typer.Context,
555
+ symbol: str = typer.Argument(..., help="Symbol name to describe."),
556
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
557
+ ) -> None:
558
+ """Node card for a symbol: definition, callers, callees, centrality, module."""
559
+ from .graph.navigate import describe_payload
560
+ from .output import json as json_renderer
561
+ from .output import markdown as md_renderer
562
+ from .storage.db import Database
563
+
564
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
565
+ db_path, _cfg = _ensure_index(ctx)
566
+ with Database(db_path) as db:
567
+ payload = describe_payload(db.conn, symbol)
568
+ typer.echo(json_renderer.render(payload) if is_json else md_renderer.render_describe(payload))
569
+
570
+
571
+ @app.command("graph")
572
+ def graph_view(
573
+ ctx: typer.Context,
574
+ target: Optional[str] = typer.Argument(None, help="Optional file path or symbol to center."),
575
+ depth: int = typer.Option(2, "--depth"),
576
+ direction: str = typer.Option("both", "--direction", help="up|down|both"),
577
+ fmt: str = typer.Option("html", "--format", help="html|graphml|dot|neo4j"),
578
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Output file path."),
579
+ open_browser: bool = typer.Option(False, "--open", help="Open the HTML graph in a browser."),
580
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
581
+ ) -> None:
582
+ """Export the graph of indexed files, symbols, and edges.
583
+
584
+ Default is an interactive HTML view (modules coloured, size by connectivity,
585
+ edge style by confidence). --format also writes graphml (Gephi/yEd), dot
586
+ (Graphviz), or neo4j (Cypher) for external graph tools.
587
+ """
588
+ import json as _json
589
+
590
+ from .graph import export as gexport
591
+ from .service import cache_dir_for
592
+ from .storage.db import Database
593
+
594
+ exporters = {
595
+ "html": (gexport.export_graph_html, "graph.html"),
596
+ "graphml": (gexport.export_graph_graphml, "graph.graphml"),
597
+ "dot": (gexport.export_graph_dot, "graph.dot"),
598
+ "neo4j": (gexport.export_graph_neo4j, "graph.cypher"),
599
+ }
600
+ if fmt not in exporters:
601
+ typer.echo(f"[codebase-index] invalid --format '{fmt}'. Valid: {', '.join(exporters)}.")
602
+ raise typer.Exit(code=2)
603
+
604
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
605
+ db_path, cfg = _ensure_index(ctx)
606
+ exporter, default_name = exporters[fmt]
607
+ out = output or cache_dir_for(cfg) / default_name
608
+
609
+ with Database(db_path) as db:
610
+ stats = exporter(db.conn, out, target=target, depth=depth, direction=direction)
611
+
612
+ if open_browser and fmt == "html":
613
+ _open_in_browser(out)
614
+
615
+ payload = {
616
+ "path": str(out),
617
+ "format": fmt,
618
+ "target": target,
619
+ "depth": depth,
620
+ "direction": direction,
621
+ **stats,
622
+ }
623
+ if is_json:
624
+ typer.echo(_json.dumps(payload))
625
+ else:
626
+ typer.echo(f"Graph ({fmt}) written to {out}")
627
+ typer.echo(f"nodes={stats['nodes']} edges={stats['edges']}")
628
+
629
+
630
+ # --- diagnostics / maintenance ------------------------------------------------------------------
631
+ @app.command()
632
+ def stats(
633
+ ctx: typer.Context,
634
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
635
+ ) -> None:
636
+ """Index size, coverage %, and freshness."""
637
+ import json as _json
638
+
639
+ from .service import resolve_db, stats_payload
640
+ from .storage.db import Database
641
+
642
+ root_opt = ctx.obj.get("root") if ctx.obj else None
643
+ db_path, _cfg = resolve_db(root_opt)
644
+
645
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
646
+
647
+ if not db_path.exists():
648
+ if is_json:
649
+ typer.echo(_json.dumps({"files": 0, "built_at": None, "exists": False}))
650
+ else:
651
+ typer.echo("No index found. Run `codebase-index index`.")
652
+ raise typer.Exit(code=0)
653
+
654
+ with Database(db_path) as db:
655
+ payload = stats_payload(db.conn)
656
+
657
+ if is_json:
658
+ typer.echo(_json.dumps(payload))
659
+ else:
660
+ typer.echo(
661
+ f"files={payload['files']} symbols={payload['symbols']} "
662
+ f"built_at={payload['built_at']} head={payload['head_commit']}"
663
+ )
664
+ for r in payload["treesitter_coverage"]:
665
+ flag = " ⚠ 0 symbols" if (r["symbols"] or 0) == 0 and r["files"] >= 3 else ""
666
+ tier = " · partial graph (Tier-B)" if r["graph"] == "partial" else ""
667
+ typer.echo(f" {r['lang']}: {r['files']} files, {r['symbols']} symbols{flag}{tier}")
668
+
669
+
670
+ @app.command()
671
+ def doctor(
672
+ ctx: typer.Context,
673
+ strict: bool = typer.Option(False, "--strict", help="Exit non-zero on high-severity findings."),
674
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
675
+ ) -> None:
676
+ """Diagnose configuration and security issues (see docs/SECURITY.md)."""
677
+ import json as _json
678
+
679
+ from .config import load
680
+ from .doctor import has_high_severity_failure, run_doctor
681
+
682
+ cfg = load(ctx.obj.get("root") if ctx.obj else None)
683
+ findings = run_doctor(Path(cfg.root), cfg)
684
+
685
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
686
+ if is_json:
687
+ typer.echo(
688
+ _json.dumps(
689
+ {
690
+ "findings": [
691
+ {"id": f.id, "ok": f.ok, "severity": f.severity, "detail": f.detail}
692
+ for f in findings
693
+ ]
694
+ }
695
+ )
696
+ )
697
+ else:
698
+ for f in findings:
699
+ mark = "OK " if f.ok else ("!! " if f.severity == "high" else "-- ")
700
+ typer.echo(f"{mark}[{f.severity}] {f.id}: {f.detail}")
701
+
702
+ if strict and has_high_severity_failure(findings):
703
+ raise typer.Exit(code=1)
704
+
705
+
706
+ @app.command()
707
+ def mcp(
708
+ ctx: typer.Context,
709
+ transport: str = typer.Option("stdio", "--transport", help="Transport: stdio (default)."),
710
+ ) -> None:
711
+ """Start the MCP server — exposes codebase-index tools to any MCP client (e.g. Claude Code).
712
+
713
+ Add to .claude/settings.json:
714
+
715
+ \\b
716
+ {
717
+ "mcpServers": {
718
+ "codebase-index": {
719
+ "command": "codebase-index",
720
+ "args": ["mcp"],
721
+ "cwd": "/path/to/your/project"
722
+ }
723
+ }
724
+ }
725
+ """
726
+ try:
727
+ from .mcp.server import mcp as _mcp
728
+ except ImportError:
729
+ typer.echo(
730
+ "[codebase-index] MCP server needs the optional extra:\n"
731
+ " pip install codebase-index[mcp]",
732
+ err=True,
733
+ )
734
+ raise typer.Exit(code=1)
735
+
736
+ root_opt = ctx.obj.get("root") if ctx.obj else None
737
+ if root_opt:
738
+ import os
739
+ os.environ.setdefault("CBX_ROOT", str(root_opt))
740
+
741
+ _mcp.run(transport=transport) # type: ignore[arg-type]
742
+
743
+
744
+ @app.command()
745
+ def clean(
746
+ ctx: typer.Context,
747
+ yes: bool = typer.Option(False, "--yes", help="Skip the confirmation prompt."),
748
+ all_cache: bool = typer.Option(
749
+ False,
750
+ "--all",
751
+ help="Remove the whole cache dir (index DB, resolved config, graph exports, "
752
+ "skill backups), not just the index database.",
753
+ ),
754
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
755
+ ) -> None:
756
+ """Reset the local index. Default removes the index DB; --all wipes the cache dir.
757
+
758
+ The installed skill (in .claude/skills/) is never touched. Rebuild with
759
+ `codebase-index index`.
760
+ """
761
+ import json as _json
762
+ import shutil
763
+
764
+ from .service import cache_dir_for, resolve_db
765
+
766
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
767
+ quiet = bool(ctx.obj and ctx.obj.get("quiet"))
768
+ root_opt = ctx.obj.get("root") if ctx.obj else None
769
+ db_path, cfg = resolve_db(root_opt)
770
+ cache_dir = cache_dir_for(cfg)
771
+
772
+ if all_cache:
773
+ targets = [cache_dir]
774
+ else:
775
+ # The index database plus its SQLite WAL/SHM sidecar files.
776
+ targets = [db_path, *(db_path.with_name(db_path.name + s) for s in ("-wal", "-shm"))]
777
+ existing = [p for p in targets if p.exists()]
778
+
779
+ if not existing:
780
+ if is_json:
781
+ typer.echo(_json.dumps({"removed": [], "existed": False}))
782
+ elif not quiet:
783
+ typer.echo("Nothing to clean (no cache found).")
784
+ raise typer.Exit(code=0)
785
+
786
+ if not yes and not is_json and sys.stdin.isatty():
787
+ what = "the entire cache directory" if all_cache else "the index database"
788
+ where = cache_dir if all_cache else db_path
789
+ typer.confirm(f"Remove {what} at {where}?", abort=True)
790
+
791
+ removed: list[str] = []
792
+ for path in existing:
793
+ if path.is_dir():
794
+ shutil.rmtree(path)
795
+ else:
796
+ path.unlink()
797
+ removed.append(str(path))
798
+
799
+ if is_json:
800
+ typer.echo(_json.dumps({"removed": removed, "existed": True}))
801
+ elif not quiet:
802
+ typer.echo(
803
+ f"Removed {len(removed)} item(s). Run `codebase-index index` to rebuild."
804
+ )
805
+
806
+
807
+ @app.command()
808
+ def watch(
809
+ ctx: typer.Context,
810
+ debounce: int = typer.Option(500, "--debounce", help="Debounce window in ms."),
811
+ ) -> None:
812
+ """Live incremental indexing via filesystem events (requires the 'watch' extra)."""
813
+ from .service import resolve_db
814
+ from .watch.watcher import run_watch
815
+
816
+ db_path, cfg = resolve_db(ctx.obj.get("root") if ctx.obj else None)
817
+ if not db_path.exists():
818
+ typer.echo("No index found. Run `codebase-index index` before `watch`.")
819
+ raise typer.Exit(code=1)
820
+
821
+ try:
822
+ run_watch(config=cfg, db_path=db_path, debounce_ms=debounce)
823
+ except RuntimeError as exc:
824
+ typer.echo(str(exc))
825
+ raise typer.Exit(code=1)
826
+
827
+
828
+ @app.command("skill-update")
829
+ def skill_update(
830
+ ctx: typer.Context,
831
+ target: Optional[str] = typer.Option(
832
+ None,
833
+ "--target",
834
+ help="Skill target to update: claude, codex, opencode (default: all installed).",
835
+ ),
836
+ no_backup: bool = typer.Option(False, "--no-backup", help="Skip backup before updating."),
837
+ force: bool = typer.Option(False, "--force", help="Update even if already on the latest version."),
838
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
839
+ ) -> None:
840
+ """Update installed skill(s) to match the current package version."""
841
+ import json as _json
842
+
843
+ from .config import find_root
844
+ from . import scaffold
845
+ from .skill_update import needs_update, update_skill
846
+
847
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
848
+ root_opt = ctx.obj.get("root") if ctx.obj else None
849
+ root = Path(root_opt).resolve() if root_opt else find_root()
850
+
851
+ targets = [target] if target else list(scaffold.CLI_TARGETS)
852
+ results = []
853
+
854
+ for t in targets:
855
+ skill_dir = root / scaffold.skill_rel_for_target(t)
856
+ if not skill_dir.exists():
857
+ results.append({"target": t, "updated": False, "reason": "not installed"})
858
+ continue
859
+
860
+ if not force and not needs_update(skill_dir):
861
+ results.append({"target": t, "updated": False, "reason": "already up to date"})
862
+ if not is_json:
863
+ typer.echo(f"[skill-update] {t}: already up to date")
864
+ continue
865
+
866
+ res = update_skill(root, t, backup=not no_backup)
867
+ results.append(res)
868
+ if not is_json:
869
+ backed = " (backup saved)" if res["backed_up"] else ""
870
+ typer.echo(
871
+ f"[skill-update] {t}: {res['old_version'] or 'unknown'} -> {res['new_version']}{backed}"
872
+ )
873
+
874
+ if is_json:
875
+ typer.echo(_json.dumps(results))
876
+
877
+
878
+ @app.command("skill-rollback")
879
+ def skill_rollback(
880
+ ctx: typer.Context,
881
+ target: Optional[str] = typer.Option(
882
+ None,
883
+ "--target",
884
+ help="Skill target to roll back: claude, codex, opencode (default: all with a backup).",
885
+ ),
886
+ json_flag: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
887
+ ) -> None:
888
+ """Restore the last backed-up version of the installed skill(s)."""
889
+ import json as _json
890
+
891
+ from .config import find_root
892
+ from . import scaffold
893
+ from .skill_update import rollback_skill
894
+
895
+ is_json = json_flag or bool(ctx.obj and ctx.obj.get("json"))
896
+ root_opt = ctx.obj.get("root") if ctx.obj else None
897
+ root = Path(root_opt).resolve() if root_opt else find_root()
898
+
899
+ targets = [target] if target else list(scaffold.CLI_TARGETS)
900
+ results = []
901
+
902
+ for t in targets:
903
+ res = rollback_skill(root, t)
904
+ results.append(res)
905
+ if not is_json:
906
+ if res["rolled_back"]:
907
+ typer.echo(f"[skill-rollback] {t}: restored from backup")
908
+ else:
909
+ typer.echo(f"[skill-rollback] {t}: {res.get('reason', 'failed')}")
910
+
911
+ if is_json:
912
+ typer.echo(_json.dumps(results))
913
+
914
+
915
+ if __name__ == "__main__":
916
+ app()