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
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()
|