codepulse-engine 0.1.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 (56) hide show
  1. codepulse/__init__.py +5 -0
  2. codepulse/api/app.py +1336 -0
  3. codepulse/api/errors.py +172 -0
  4. codepulse/api/security.py +189 -0
  5. codepulse/auth.py +406 -0
  6. codepulse/cli.py +1052 -0
  7. codepulse/config.py +475 -0
  8. codepulse/data_location.py +78 -0
  9. codepulse/embeddings/chunker.py +138 -0
  10. codepulse/embeddings/embedder.py +120 -0
  11. codepulse/git_diff.py +201 -0
  12. codepulse/indexer/engine.py +2655 -0
  13. codepulse/indexer/ignore.py +120 -0
  14. codepulse/ingestion_safety.py +169 -0
  15. codepulse/jobs.py +257 -0
  16. codepulse/mcp_server.py +269 -0
  17. codepulse/models.py +140 -0
  18. codepulse/observability.py +130 -0
  19. codepulse/parser/__init__.py +8 -0
  20. codepulse/parser/base.py +58 -0
  21. codepulse/parser/factory.py +37 -0
  22. codepulse/parser/graph.py +502 -0
  23. codepulse/parser/javascript.py +428 -0
  24. codepulse/parser/module_graph.py +232 -0
  25. codepulse/parser/python.py +331 -0
  26. codepulse/rate_limit.py +142 -0
  27. codepulse/repository_identity.py +83 -0
  28. codepulse/search/bm25.py +342 -0
  29. codepulse/search/ranker.py +382 -0
  30. codepulse/search/tokenizer.py +80 -0
  31. codepulse/search/vector.py +357 -0
  32. codepulse/storage/db.py +526 -0
  33. codepulse/storage/migrations.py +347 -0
  34. codepulse/storage/schema_migrations/README +1 -0
  35. codepulse/storage/schema_migrations/env.py +70 -0
  36. codepulse/storage/schema_migrations/script.py.mako +28 -0
  37. codepulse/storage/schema_migrations/versions/135a7b064ea7_add_base_classes_and_relationship_type.py +82 -0
  38. codepulse/storage/schema_migrations/versions/20fc2d0607e2_add_file_content_hash_and_natural_key_.py +106 -0
  39. codepulse/storage/schema_migrations/versions/21a7cb7f1a85_initial_schema.py +105 -0
  40. codepulse/storage/schema_migrations/versions/2cd38eb12a7e_add_call_edge_repo_id_isolation_fix.py +112 -0
  41. codepulse/storage/schema_migrations/versions/3ba396e9c78f_add_symbol_natural_key_uniqueness.py +80 -0
  42. codepulse/storage/schema_migrations/versions/49572f834366_add_embedding_persistence_fields.py +102 -0
  43. codepulse/storage/schema_migrations/versions/7c3e5a91d2f4_add_module_edges.py +61 -0
  44. codepulse/storage/schema_migrations/versions/a41f0d2c6b8e_canonicalize_repository_urls.py +94 -0
  45. codepulse/storage/schema_migrations/versions/b0bdd63949f4_add_api_key_model.py +71 -0
  46. codepulse/storage/schema_migrations/versions/b9014c049e33_add_api_key_repository_role.py +65 -0
  47. codepulse/storage/schema_migrations/versions/c286d277ea21_add_repository_snapshot_model.py +62 -0
  48. codepulse/storage/schema_migrations/versions/de22df2fc3ce_add_index_job_model.py +59 -0
  49. codepulse/storage/schema_migrations/versions/f6e481353553_rename_dependency_to_call_edge_add_confidence.py +80 -0
  50. codepulse/symbol_identity.py +29 -0
  51. codepulse_engine-0.1.0.dist-info/METADATA +191 -0
  52. codepulse_engine-0.1.0.dist-info/RECORD +56 -0
  53. codepulse_engine-0.1.0.dist-info/WHEEL +5 -0
  54. codepulse_engine-0.1.0.dist-info/entry_points.txt +2 -0
  55. codepulse_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
  56. codepulse_engine-0.1.0.dist-info/top_level.txt +1 -0
codepulse/cli.py ADDED
@@ -0,0 +1,1052 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import time
8
+ from collections.abc import Mapping
9
+ from typing import Any
10
+
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
14
+ from rich.syntax import Syntax
15
+ from rich.table import Table
16
+ from rich.tree import Tree
17
+
18
+ from sqlalchemy import text
19
+
20
+ from codepulse.embeddings.embedder import CodeEmbedder
21
+ from codepulse.indexer.engine import IndexingEngine
22
+ from codepulse.parser.factory import get_parser_for_file
23
+ from codepulse.parser.graph import CallGraphBuilder
24
+ from codepulse.storage.db import StorageManager
25
+
26
+ if sys.platform == "win32":
27
+ try:
28
+ sys.stdout.reconfigure(encoding="utf-8")
29
+ sys.stderr.reconfigure(encoding="utf-8")
30
+ except Exception:
31
+ pass
32
+
33
+ console = Console(safe_box=True)
34
+ _stderr_console = Console(safe_box=True, stderr=True)
35
+
36
+ # Subcommands whose positional `path` is a repository directory that must exist.
37
+ _REPOSITORY_PATH_COMMANDS = frozenset(
38
+ {"index", "update", "search", "impact", "symbol", "refs", "callers", "callees", "similar", "status", "modules"}
39
+ )
40
+
41
+
42
+ def _display_db_path(db_url: str) -> str:
43
+ """
44
+ Resolves a `sqlite:///<path>` URL's <path> component to an absolute
45
+ path (against the CURRENT working directory, matching how
46
+ `create_engine` itself would resolve it) purely for display -- so
47
+ "which exact file is this command about to touch" is unambiguous,
48
+ regardless of whether `db_url` came from a CLI flag, an env var, a
49
+ relative `codepulse.toml` entry, or the plain relative default.
50
+ Non-sqlite URLs (e.g. a future Postgres backend) are shown as-is.
51
+ """
52
+ prefix = "sqlite:///"
53
+ if not db_url.startswith(prefix):
54
+ return db_url
55
+ raw_path = db_url[len(prefix):]
56
+ if os.path.isabs(raw_path):
57
+ return db_url
58
+ return prefix + os.path.abspath(raw_path).replace(os.sep, "/")
59
+
60
+
61
+ def cmd_parse(args: argparse.Namespace) -> None:
62
+ """Parses a single file and displays extracted AST symbols and complexity."""
63
+ file_path = args.file_path
64
+ if not os.path.exists(file_path):
65
+ console.print(f"[bold red]Error:[/bold red] File not found: {file_path}")
66
+ sys.exit(1)
67
+
68
+ parser = get_parser_for_file(file_path)
69
+ if not parser:
70
+ console.print(f"[bold red]Error:[/bold red] No parser available for file: {file_path}")
71
+ sys.exit(1)
72
+
73
+ with open(file_path, "r", encoding="utf-8", errors="replace") as f:
74
+ content = f.read()
75
+
76
+ result = parser.parse_file(file_path, content)
77
+
78
+ console.print(
79
+ Panel.fit(
80
+ f"[bold cyan]File:[/bold cyan] {file_path}\n"
81
+ f"[bold cyan]Language:[/bold cyan] {result.language.upper()}\n"
82
+ f"[bold cyan]Lines:[/bold cyan] {result.line_count} | [bold cyan]Size:[/bold cyan] {result.file_size_bytes} bytes",
83
+ title="🔍 AST Parse Result",
84
+ border_style="cyan",
85
+ )
86
+ )
87
+
88
+ # 1. Symbols Table
89
+ sym_table = Table(title="✨ Extracted Symbols", border_style="blue", show_header=True)
90
+ sym_table.add_column("Type", style="magenta", width=12)
91
+ sym_table.add_column("Name", style="bold green", width=25)
92
+ sym_table.add_column("Lines", style="yellow", width=10)
93
+ sym_table.add_column("Complexity", style="cyan", width=12)
94
+ sym_table.add_column("Signature / Docstring", style="white")
95
+
96
+ for sym in result.symbols:
97
+ doc_preview = f"\n[italic dim]{sym.docstring[:80]}...[/italic dim]" if sym.docstring else ""
98
+ sym_table.add_row(
99
+ sym.type.value if hasattr(sym.type, "value") else str(sym.type),
100
+ sym.name,
101
+ f"L{sym.start_line}-{sym.end_line}",
102
+ str(sym.complexity),
103
+ f"[bold]{sym.signature}[/bold]{doc_preview}",
104
+ )
105
+
106
+ console.print(sym_table)
107
+
108
+ # 2. Imports Table
109
+ if result.imports:
110
+ imp_table = Table(title="📦 Imports", border_style="magenta", show_header=True)
111
+ imp_table.add_column("Module", style="bold yellow")
112
+ imp_table.add_column("Items / Aliases", style="white")
113
+ imp_table.add_column("Line", style="cyan")
114
+
115
+ for imp in result.imports:
116
+ items_str = ", ".join(f"{it.name}" + (f" as {it.alias}" if it.alias else "") for it in imp.items)
117
+ imp_table.add_row(imp.module or "(relative)", items_str or "*", str(imp.line_number))
118
+
119
+ console.print(imp_table)
120
+
121
+ # 3. Call Invocations
122
+ if result.calls:
123
+ call_table = Table(title="📞 Function Calls", border_style="green", show_header=True)
124
+ call_table.add_column("Caller", style="bold cyan")
125
+ call_table.add_column("Callee", style="bold green")
126
+ call_table.add_column("Line", style="yellow")
127
+
128
+ for c in result.calls:
129
+ call_table.add_row(c.caller_symbol, c.callee_name, str(c.line_number))
130
+
131
+ console.print(call_table)
132
+
133
+
134
+ def cmd_index_and_search(args: argparse.Namespace) -> None:
135
+ """Indexes a repository and opens an interactive search prompt or executes a search."""
136
+ path = args.path
137
+ if not os.path.exists(path):
138
+ console.print(f"[bold red]Error:[/bold red] Path not found: {path}")
139
+ sys.exit(1)
140
+
141
+ console.print(f"[bold cyan]⚡ Indexing repository:[/bold cyan] {path}...")
142
+ start_time = time.time()
143
+
144
+ engine = IndexingEngine()
145
+
146
+ with Progress(
147
+ SpinnerColumn(),
148
+ TextColumn("[progress.description]{task.description}"),
149
+ BarColumn(),
150
+ TimeElapsedColumn(),
151
+ console=console,
152
+ ) as progress:
153
+ task = progress.add_task("[cyan]Processing files...", total=100)
154
+
155
+ def cb(rel_path: str, curr: int, total: int):
156
+ progress.update(task, completed=int((curr / max(1, total)) * 100), description=f"[cyan]{rel_path[:30]}")
157
+
158
+ stats = engine.index_directory(path, repo_name=os.path.basename(path), progress_callback=cb)
159
+
160
+ elapsed = time.time() - start_time
161
+ console.print(
162
+ Panel(
163
+ f"[bold green]✓ Indexing Complete in {elapsed:.2f}s[/bold green]\n"
164
+ f"Files: [bold]{stats['total_files']}[/bold] | Symbols: [bold]{stats['total_symbols']}[/bold] | "
165
+ f"Chunks: [bold]{stats['total_chunks']}[/bold] | Call Graph Edges: [bold]{stats['call_graph_edges']}[/bold]",
166
+ title="📊 Repository Stats",
167
+ border_style="green",
168
+ )
169
+ )
170
+
171
+ # If a query is provided directly
172
+ if args.query:
173
+ _perform_search(engine, args.query, args.type, args.limit, json_output=getattr(args, "json", False))
174
+ else:
175
+ # Interactive REPL mode
176
+ console.print("[bold yellow]Interactive Search Mode. Type 'exit' to quit.[/bold yellow]\n")
177
+ while True:
178
+ try:
179
+ query = console.input("[bold cyan]CodePulse Search > [/bold cyan]").strip()
180
+ if query.lower() in ("exit", "quit", "q"):
181
+ break
182
+ if not query:
183
+ continue
184
+
185
+ _perform_search(engine, query, args.type, args.limit)
186
+ except KeyboardInterrupt:
187
+ break
188
+
189
+
190
+ def _perform_search(engine: IndexingEngine, query: str, search_type: str, limit: int, json_output: bool = False) -> None:
191
+ t0 = time.time()
192
+ results = engine.search(query, limit=limit, search_type=search_type)
193
+ search_ms = (time.time() - t0) * 1000
194
+
195
+ if json_output:
196
+ print(json.dumps({"query": query, "search_type": search_type, "results": [r.to_dict() for r in results]}, indent=2, default=str))
197
+ return
198
+
199
+ if not results:
200
+ console.print(f"[yellow]No matching code symbols found for '{query}'[/yellow]\n")
201
+ return
202
+
203
+ table = Table(
204
+ title=f"🔎 Search Results for '{query}' ({search_type.upper()} - {search_ms:.1f}ms)",
205
+ border_style="cyan",
206
+ show_header=True,
207
+ )
208
+ table.add_column("Rank", style="dim", width=5)
209
+ table.add_column("Symbol", style="bold green", width=24)
210
+ table.add_column("File : Line", style="yellow", width=30)
211
+ table.add_column("Score", style="bold cyan", width=8)
212
+ table.add_column("Breakdown (KW / Vec / Graph)", style="magenta", width=25)
213
+ table.add_column("Signature / Preview", style="white")
214
+
215
+ for rank, res in enumerate(results, start=1):
216
+ score_breakdown = f"{res.keyword_score:.2f} / {res.vector_score:.2f} / {res.graph_score:.2f}"
217
+ table.add_row(
218
+ str(rank),
219
+ f"[{res.symbol_type}] {res.symbol_name}",
220
+ f"{res.file_path}:{res.start_line}",
221
+ f"{res.final_score:.2f}",
222
+ score_breakdown,
223
+ res.signature or res.snippet[:80],
224
+ )
225
+
226
+ console.print(table)
227
+ console.print()
228
+
229
+
230
+ def cmd_impact(args: argparse.Namespace) -> None:
231
+ """Performs upstream and downstream blast-radius impact analysis on a symbol."""
232
+ path = args.path
233
+ symbol_name = args.symbol
234
+ engine = IndexingEngine()
235
+ result = engine.index_directory(path)
236
+
237
+ if not engine.call_graph:
238
+ if getattr(args, "json", False):
239
+ print(json.dumps({"error": "call_graph_not_available", "message": "Call graph not available."}))
240
+ else:
241
+ console.print("[red]Call graph not available.[/red]")
242
+ return
243
+
244
+ # Phase 4.2 (roadmap §37 item 4.2): redirected to the shared
245
+ # `IndexingEngine.analyze_impact` intelligence-core method instead of
246
+ # calling `CallGraphBuilder.get_callers`/`get_callees` directly (this
247
+ # milestone's own audit found that duplicated the REST route's
248
+ # identical logic a second time here). `result["repository_id"]` is
249
+ # already returned by `index_directory` above -- no new CLI feature
250
+ # or flag needed to plumb it through. The CLI's own presentation
251
+ # (this Rich `Tree`) is unchanged.
252
+ repository_id = result["repository_id"]
253
+ impact = engine.analyze_impact(symbol_name, "", repository_id, max_depth=args.depth)
254
+ if getattr(args, "json", False):
255
+ print(json.dumps(impact, indent=2, default=str))
256
+ return
257
+
258
+ console.print(Panel(f"[bold magenta]Impact Analysis for symbol:[/bold magenta] [bold green]{symbol_name}[/bold green]"))
259
+ callers = impact["call_chain"]
260
+ callees = impact["dependencies"]
261
+
262
+ # Tree visualization
263
+ tree = Tree(f"[bold yellow]🎯 Symbol: {symbol_name}[/bold yellow]")
264
+
265
+ callers_branch = tree.add(f"[bold red]▲ Blast Radius (Upstream Callers - {len(callers)} found)[/bold red]")
266
+ for c in callers:
267
+ callers_branch.add(f"[cyan]{c['symbol']}[/cyan] ({c['file']}) [dim]depth={c['depth']}[/dim] [dim]confidence={c.get('confidence')}[/dim]")
268
+
269
+ callees_branch = tree.add(f"[bold blue]▼ Dependencies (Downstream Callees - {len(callees)} found)[/bold blue]")
270
+ for c in callees:
271
+ callees_branch.add(f"[cyan]{c['symbol']}[/cyan] ({c['file']}) [dim]depth={c['depth']}[/dim] [dim]confidence={c.get('confidence')}[/dim]")
272
+
273
+ console.print(tree)
274
+ console.print()
275
+
276
+
277
+ # --- Phase 6.1 (roadmap §37 item 6.1 "Full CLI command set"; §22 CLI
278
+ # Strategy; §45 backlog B21: "All §22 commands implemented with --json
279
+ # output"). Every command below follows the exact same established
280
+ # pattern `search`/`impact` already use: construct a fresh
281
+ # `IndexingEngine`, call `index_directory(path)` (cheap and idempotent
282
+ # for an already-indexed, unchanged repository, per Phase 3's own
283
+ # incremental-indexing machinery -- not a new "persistent session"
284
+ # mechanism), then delegate to the SAME shared `IndexingEngine.analyze_*`
285
+ # methods the REST/MCP adapters already call. No business logic is
286
+ # reimplemented here. --------------------------------------------------
287
+
288
+ def _print_json(data) -> None:
289
+ print(json.dumps(data, indent=2, default=str))
290
+
291
+
292
+ def cmd_init(args: argparse.Namespace) -> None:
293
+ """Creates a starter codepulse.toml in the target directory -- no infra required (§25 Local Deployment Strategy)."""
294
+ target_dir = args.path
295
+ os.makedirs(target_dir, exist_ok=True)
296
+ config_path = os.path.join(target_dir, "codepulse.toml")
297
+ if os.path.exists(config_path) and not args.force:
298
+ console.print(f"[yellow]{config_path} already exists -- use --force to overwrite.[/yellow]")
299
+ return
300
+
301
+ from codepulse.config import CodePulseConfig
302
+ defaults = CodePulseConfig()
303
+ with open(config_path, "w", encoding="utf-8") as f:
304
+ f.write(
305
+ "# CodePulse local configuration -- see docs/CodePulse_Strategy_and_Roadmap.md §25.\n"
306
+ "# Precedence: CLI flags > CODEPULSE_* env vars > this file > built-in defaults.\n\n"
307
+ "# Keep the database in THIS directory (project-local). Remove the line to use the default: one database\n"
308
+ "# per user, in your operating system's per-user data directory.\n"
309
+ 'db_url = "sqlite:///codepulse.db"\n'
310
+ f'embedding_model = "{defaults.embedding_model}"\n'
311
+ )
312
+ console.print(f"[bold green]✓[/bold green] Created {config_path}")
313
+
314
+
315
+ def cmd_index(args: argparse.Namespace) -> None:
316
+ """Full index of a repository (standalone -- `search` already indexes as a side effect; this command exists for a plain `codepulse index <path>` workflow with no query)."""
317
+ engine = IndexingEngine()
318
+ result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
319
+ if args.json:
320
+ _print_json(result)
321
+ return
322
+ console.print(
323
+ Panel(
324
+ f"[bold green]✓ Indexed[/bold green] {args.path}\n"
325
+ f"Repository id: [bold]{result['repository_id']}[/bold] | "
326
+ f"Files: [bold]{result.get('total_files', 0)}[/bold] | "
327
+ f"Incremental: [bold]{result.get('incremental', False)}[/bold]",
328
+ title="📊 Index Result",
329
+ border_style="green",
330
+ )
331
+ )
332
+
333
+
334
+ def cmd_update(args: argparse.Namespace) -> None:
335
+ """Incremental (git-diff based when available) re-index -- a thin, clearer-intent alias over the same index_directory() call, which already detects and prefers an incremental run automatically (Phase 3.1)."""
336
+ engine = IndexingEngine()
337
+ result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
338
+ if args.json:
339
+ _print_json(result)
340
+ return
341
+ kind = "incremental" if result.get("incremental") else "full"
342
+ console.print(f"[bold green]✓[/bold green] Update complete ({kind}) -- {result.get('total_files', 0)} file(s) processed.")
343
+
344
+
345
+ def _resolve_symbol(engine: IndexingEngine, repo_id: int, name: str):
346
+ """Looks up a symbol by (repo_id, name) -- a repo can have more than
347
+ one symbol sharing a name (overloads, methods on different
348
+ classes), so this returns the full match list, never silently
349
+ picking one."""
350
+ from codepulse.storage.db import FileModel, SymbolModel
351
+
352
+ session = engine.storage.get_session()
353
+ try:
354
+ matches = (
355
+ session.query(SymbolModel)
356
+ .join(FileModel)
357
+ .filter(FileModel.repo_id == repo_id, SymbolModel.name == name)
358
+ .all()
359
+ )
360
+ return [
361
+ {
362
+ "id": s.id, "name": s.name, "type": s.type, "file_path": s.file.path,
363
+ "signature": s.signature, "docstring": s.docstring, "body": s.body,
364
+ "line_start": s.start_line, "line_end": s.end_line,
365
+ }
366
+ for s in matches
367
+ ]
368
+ finally:
369
+ session.close()
370
+
371
+
372
+ def cmd_symbol(args: argparse.Namespace) -> None:
373
+ """Looks up and displays one symbol's definition."""
374
+ engine = IndexingEngine()
375
+ result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
376
+ matches = _resolve_symbol(engine, result["repository_id"], args.symbol)
377
+
378
+ if not matches:
379
+ if args.json:
380
+ _print_json({"error": "symbol_not_found", "message": f"No symbol named {args.symbol!r}."})
381
+ else:
382
+ console.print(f"[red]No symbol named '{args.symbol}' found.[/red]")
383
+ return
384
+
385
+ if args.json:
386
+ _print_json({"matches": matches})
387
+ return
388
+
389
+ for m in matches:
390
+ console.print(
391
+ Panel(
392
+ f"[bold cyan]{m['signature']}[/bold cyan]\n\n"
393
+ f"{m['docstring'] or '[dim](no docstring)[/dim]'}\n\n"
394
+ f"[dim]{m['file_path']}:{m['line_start']}-{m['line_end']}[/dim]",
395
+ title=f"[{m['type']}] {m['name']}",
396
+ border_style="cyan",
397
+ )
398
+ )
399
+
400
+
401
+ def cmd_refs(args: argparse.Namespace) -> None:
402
+ """Direct references (callers + direct subclasses) -- Phase 4.4's IndexingEngine.analyze_references, reused unmodified."""
403
+ engine = IndexingEngine()
404
+ result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
405
+ refs = engine.analyze_references(args.symbol, "", result["repository_id"])
406
+ if args.json:
407
+ _print_json(refs)
408
+ return
409
+
410
+ tree = Tree(f"[bold yellow]References: {args.symbol}[/bold yellow]")
411
+ callers_branch = tree.add(f"[bold cyan]Direct callers ({len(refs['callers'])})[/bold cyan]")
412
+ for c in refs["callers"]:
413
+ callers_branch.add(f"{c['symbol']} ({c['file']}) [dim]confidence={c.get('confidence')}[/dim]")
414
+ sub_branch = tree.add(f"[bold magenta]Direct subclasses ({len(refs['subclasses'])})[/bold magenta]")
415
+ for s in refs["subclasses"]:
416
+ sub_branch.add(f"{s['symbol']} ({s['file']}) [dim]confidence={s.get('confidence')}[/dim]")
417
+ console.print(tree)
418
+
419
+
420
+ def cmd_callers(args: argparse.Namespace) -> None:
421
+ """Callers of a symbol, direct + transitive (a projection of IndexingEngine.analyze_impact)."""
422
+ engine = IndexingEngine()
423
+ result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
424
+ impact = engine.analyze_impact(args.symbol, "", result["repository_id"], max_depth=args.depth)
425
+ if args.json:
426
+ _print_json({"symbol": args.symbol, "callers": impact["call_chain"]})
427
+ return
428
+ tree = Tree(f"[bold red]▲ Callers of {args.symbol} ({len(impact['call_chain'])} found)[/bold red]")
429
+ for c in impact["call_chain"]:
430
+ tree.add(f"{c['symbol']} ({c['file']}) [dim]depth={c['depth']} confidence={c.get('confidence')}[/dim]")
431
+ console.print(tree)
432
+
433
+
434
+ def cmd_callees(args: argparse.Namespace) -> None:
435
+ """Symbols called BY a symbol, direct + transitive (a projection of IndexingEngine.analyze_impact)."""
436
+ engine = IndexingEngine()
437
+ result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
438
+ impact = engine.analyze_impact(args.symbol, "", result["repository_id"], max_depth=args.depth)
439
+ if args.json:
440
+ _print_json({"symbol": args.symbol, "callees": impact["dependencies"]})
441
+ return
442
+ tree = Tree(f"[bold blue]▼ Callees of {args.symbol} ({len(impact['dependencies'])} found)[/bold blue]")
443
+ for c in impact["dependencies"]:
444
+ tree.add(f"{c['symbol']} ({c['file']}) [dim]depth={c['depth']} confidence={c.get('confidence')}[/dim]")
445
+ console.print(tree)
446
+
447
+
448
+ def cmd_similar(args: argparse.Namespace) -> None:
449
+ """Semantically similar symbols (IndexingEngine.find_similar_symbols, Phase 5.1)."""
450
+ engine = IndexingEngine()
451
+ result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
452
+ matches = _resolve_symbol(engine, result["repository_id"], args.symbol)
453
+ if not matches:
454
+ if args.json:
455
+ _print_json({"error": "symbol_not_found", "message": f"No symbol named {args.symbol!r}."})
456
+ else:
457
+ console.print(f"[red]No symbol named '{args.symbol}' found.[/red]")
458
+ return
459
+
460
+ m = matches[0]
461
+ query_text = f"{m['signature']}\n{m['docstring'] or ''}\n{m['body'][:500]}"
462
+ similar = engine.find_similar_symbols(
463
+ query_text, m["name"], result["repository_id"], threshold=args.threshold, limit=args.limit
464
+ )
465
+ if args.json:
466
+ _print_json({"query_symbol": {"id": m["id"], "name": m["name"]}, "similar": similar})
467
+ return
468
+
469
+ table = Table(title=f"Symbols similar to {args.symbol}", border_style="cyan")
470
+ table.add_column("Symbol", style="bold green")
471
+ table.add_column("File", style="yellow")
472
+ table.add_column("Relevance", style="cyan")
473
+ for s in similar:
474
+ table.add_row(s["symbol_name"], s["file_path"], f"{s['relevance_score']:.2f}")
475
+ console.print(table)
476
+
477
+
478
+ def cmd_status(args: argparse.Namespace) -> None:
479
+ """Index freshness -- last snapshot commit/time, file/symbol counts. Honest, not fabricated: no job history is reported (local CLI indexing runs synchronously and never creates IndexJob rows -- those are REST's async ingestion flow, Phase 1.3)."""
480
+ from codepulse.storage.db import RepositoryModel, RepositorySnapshotModel
481
+
482
+ engine = IndexingEngine()
483
+ session = engine.storage.get_session()
484
+ try:
485
+ from codepulse.repository_identity import canonical_repository_url, find_repository
486
+
487
+ repo_url = canonical_repository_url(args.path)
488
+ repo = find_repository(session, args.path)
489
+ if not repo:
490
+ payload = {"indexed": False, "path": repo_url}
491
+ if args.json:
492
+ _print_json(payload)
493
+ else:
494
+ console.print(f"[yellow]{repo_url} has not been indexed yet -- run `codepulse index {args.path}`.[/yellow]")
495
+ return
496
+
497
+ snapshot = (
498
+ session.query(RepositorySnapshotModel)
499
+ .filter(RepositorySnapshotModel.repo_id == repo.id)
500
+ .order_by(RepositorySnapshotModel.id.desc())
501
+ .first()
502
+ )
503
+ payload = {
504
+ "indexed": True,
505
+ "repository_id": repo.id,
506
+ "path": repo_url,
507
+ "file_count": repo.file_count,
508
+ "symbol_count": repo.symbol_count,
509
+ "last_snapshot_commit": snapshot.commit_sha if snapshot else None,
510
+ "last_indexed_at": snapshot.indexed_at if snapshot else None,
511
+ }
512
+ finally:
513
+ session.close()
514
+
515
+ if args.json:
516
+ _print_json(payload)
517
+ return
518
+ console.print(
519
+ Panel(
520
+ f"Repository id: [bold]{payload['repository_id']}[/bold]\n"
521
+ f"Files: [bold]{payload['file_count']}[/bold] | Symbols: [bold]{payload['symbol_count']}[/bold]\n"
522
+ f"Last snapshot commit: [bold]{payload['last_snapshot_commit'] or '(none / non-git)'}[/bold]\n"
523
+ f"Last indexed at: [bold]{payload['last_indexed_at']}[/bold]",
524
+ title=f"📈 Status: {args.path}",
525
+ border_style="cyan",
526
+ )
527
+ )
528
+
529
+
530
+ def cmd_modules(args: argparse.Namespace) -> None:
531
+ """
532
+ Module clusters + raw inbound/outbound degree rankings, via
533
+ IndexingEngine.analyze_module_structure (Phase 4.3's ModuleGraphBuilder,
534
+ reused unmodified). Backend-hardening addition (not a roadmap sprint
535
+ item) -- this engine capability existed since Phase 4.3 with no CLI
536
+ caller until now.
537
+ """
538
+ engine = IndexingEngine()
539
+ result = engine.index_directory(args.path, repo_name=os.path.basename(args.path))
540
+ module_graph = engine.analyze_module_structure(result["repository_id"])
541
+ payload = {
542
+ "repository_id": result["repository_id"],
543
+ "clusters": module_graph.clusters,
544
+ "ranked_by_inbound": module_graph.rank_by_inbound(),
545
+ "ranked_by_outbound": module_graph.rank_by_outbound(),
546
+ }
547
+ if args.json:
548
+ _print_json(payload)
549
+ return
550
+
551
+ tree = Tree(f"[bold magenta]Module clusters ({len(payload['clusters'])})[/bold magenta]")
552
+ for cluster in payload["clusters"]:
553
+ branch = tree.add(f"[bold]Cluster {cluster['id']}[/bold] ({len(cluster['files'])} files)")
554
+ for f in cluster["files"]:
555
+ branch.add(f)
556
+ console.print(tree)
557
+
558
+ table = Table(title="Top files by inbound edges (possible API/entry points, per §11's literal heuristic)", border_style="cyan")
559
+ table.add_column("File", style="yellow")
560
+ table.add_column("In", justify="right")
561
+ table.add_column("Out", justify="right")
562
+ for row in payload["ranked_by_inbound"][:10]:
563
+ table.add_row(row["file"], str(row["in_degree"]), str(row["out_degree"]))
564
+ console.print(table)
565
+
566
+
567
+ def _schema_warning(status) -> str | None:
568
+ """One line telling the user their database needs `codepulse migrate`, or None when nothing is wrong."""
569
+ if status.state == "behind":
570
+ return f"Database schema is {len(status.pending)} revision(s) behind ({status.current} -> {status.head}): run `codepulse migrate`."
571
+ if status.state == "unstamped":
572
+ extra = f" and create missing table(s) {', '.join(status.missing_tables)}" if status.missing_tables else ""
573
+ return f"Database is not tracked by Alembic: run `codepulse migrate` (its schema matches, so it will just be stamped{extra})."
574
+ if status.state == "diverged":
575
+ return "Database schema differs from this CodePulse and cannot be stamped automatically: run `codepulse migrate` for details."
576
+ if status.state == "unknown":
577
+ return f"Database is at a revision this CodePulse does not know ({status.current}); it may belong to a newer version."
578
+ return None
579
+
580
+
581
+ def cmd_migrate(args: argparse.Namespace) -> None:
582
+ """
583
+ Brings the database to the current schema, safely (item 17): creates a new one, upgrades one that is
584
+ behind (after a consistent backup of the SQLite file), stamps one that CodePulse created itself before
585
+ migrations were tracked (only if its schema matches the models), and REFUSES -- changing nothing -- one
586
+ that differs or comes from a newer CodePulse. `--check` only reports. Exit code: 0 up to date or done,
587
+ 1 needs an upgrade/stamp (with --check) or was refused, 2 refused as unsafe in --check mode.
588
+ """
589
+ from codepulse.config import get_settings
590
+ from codepulse.storage.migrations import MigrationRefused, get_schema_status, migrate_database
591
+
592
+ settings = get_settings()
593
+ try:
594
+ result = migrate_database(settings.db_url, backup=not args.no_backup, check=args.check)
595
+ except MigrationRefused as e:
596
+ status = get_schema_status(settings.db_url)
597
+ if args.json:
598
+ _print_json({"action": "refused", "state": status.state, "from": status.current, "to": status.head,
599
+ "differences": status.differences, "message": str(e)})
600
+ else:
601
+ _stderr_console.print(f"[bold red]Refused:[/bold red] {e}")
602
+ for d in status.differences:
603
+ _stderr_console.print(f" - {d}")
604
+ sys.exit(1)
605
+
606
+ payload = {
607
+ "action": result.action, "state": result.state, "from": result.from_revision, "to": result.to_revision,
608
+ "applied": result.applied, "backup": result.backup_path, "detail": result.detail,
609
+ }
610
+ if args.json:
611
+ _print_json(payload)
612
+ else:
613
+ colour = {"created": "green", "upgraded": "green", "stamped": "green", "up-to-date": "green"}.get(result.action, "yellow")
614
+ console.print(f"[{colour}]{result.action}[/{colour}]: {result.detail or result.state}")
615
+ if result.backup_path:
616
+ console.print(f"[dim]backup of the previous state: {result.backup_path}[/dim]")
617
+ if result.action in ("needs-upgrade", "needs-stamp"):
618
+ console.print("Run `codepulse migrate` to apply.")
619
+ if args.check:
620
+ sys.exit({"up-to-date": 0, "needs-upgrade": 1, "needs-stamp": 1, "blocked": 2}.get(result.action, 0))
621
+
622
+
623
+ def cmd_key_create(args: argparse.Namespace) -> None:
624
+ """
625
+ Creates an API key scoped to the given folders: how a new user gets their first key (a fresh install has none, and
626
+ `POST /v1/admin/keys` needs an admin key to exist already). A folder not in the database yet is registered as an
627
+ empty repository, so the editor can then index it with this key. The raw key is printed once, on stdout, and only
628
+ its hash is stored. A key with no repository grant can do nothing, so at least one `--repo` is required.
629
+ """
630
+ from codepulse.auth import create_api_key, register_repository
631
+ from codepulse.config import get_settings
632
+ from codepulse.repository_identity import canonical_repository_url
633
+ from codepulse.storage.db import StorageManager
634
+
635
+ storage = StorageManager(db_url=get_settings().db_url)
636
+ repositories: list[dict[str, Any]] = []
637
+ seen: set[str] = set()
638
+ for path in args.repo:
639
+ canonical = canonical_repository_url(os.path.abspath(path))
640
+ if canonical in seen:
641
+ continue
642
+ seen.add(canonical)
643
+ name = os.path.basename(canonical.rstrip("/\\")) or canonical
644
+ repo_id, created = register_repository(storage, name, canonical)
645
+ repositories.append({"id": repo_id, "name": name, "path": canonical, "registered": created})
646
+
647
+ created_key = create_api_key(storage, args.name, [r["id"] for r in repositories], role=args.role)
648
+
649
+ if args.json:
650
+ _print_json({
651
+ "id": created_key.id, "name": created_key.name, "role": args.role, "key": created_key.raw_key,
652
+ "repositories": repositories,
653
+ })
654
+ return
655
+
656
+ console.print(f"[bold green]✓ Created API key[/bold green] '{created_key.name}' (id {created_key.id}, role {args.role}) for:")
657
+ for r in repositories:
658
+ note = "registered, not indexed yet" if r["registered"] else "already known"
659
+ console.print(f" {r['id']} {r['name']} [dim]{r['path']} ({note})[/dim]")
660
+ console.print("[bold]Your key (shown once and never stored, so copy it now):[/bold]")
661
+ print(created_key.raw_key) # plain print: exactly the key, on a line of its own
662
+ console.print("Next, in VS Code (Command Palette):")
663
+ console.print(" 1. [bold]CodePulse: Set API Key[/bold] and paste the key")
664
+ console.print(" 2. [bold]CodePulse: Index Workspace[/bold]")
665
+
666
+
667
+ def cmd_doctor(args: argparse.Namespace) -> None:
668
+ """Environment/config diagnostics -- embedding model loads? DB reachable? ignore rules active?"""
669
+ from codepulse.config import get_settings
670
+ from codepulse.indexer.ignore import RepositoryIgnoreRules
671
+
672
+ settings = get_settings()
673
+ checks: list[dict] = []
674
+
675
+ checks.append({"check": "config", "ok": True, "detail": f"db_url={settings.db_url}, embedding_model={settings.embedding_model}"})
676
+
677
+ try:
678
+ storage = StorageManager(db_url=settings.db_url)
679
+ session = storage.get_session()
680
+ session.execute(text("SELECT 1"))
681
+ session.close()
682
+ checks.append({"check": "database_reachable", "ok": True, "detail": settings.db_url})
683
+ except Exception as e:
684
+ checks.append({"check": "database_reachable", "ok": False, "detail": str(e)})
685
+
686
+ try:
687
+ from codepulse.storage.migrations import get_schema_status
688
+
689
+ schema = get_schema_status(settings.db_url)
690
+ warning = _schema_warning(schema)
691
+ checks.append({"check": "database_schema", "ok": warning is None, "detail": warning or schema.detail})
692
+ except Exception as e:
693
+ checks.append({"check": "database_schema", "ok": False, "detail": str(e)})
694
+
695
+ try:
696
+ embedder = CodeEmbedder()
697
+ _ = embedder.encode("def f(): pass")
698
+ checks.append({"check": "embedding_model_loads", "ok": True, "detail": settings.embedding_model})
699
+ except Exception as e:
700
+ checks.append({"check": "embedding_model_loads", "ok": False, "detail": str(e)})
701
+
702
+ target = args.path or "."
703
+ try:
704
+ rules = RepositoryIgnoreRules(target)
705
+ checks.append({"check": "ignore_rules_active", "ok": True, "detail": f"{target}"})
706
+ except Exception as e:
707
+ checks.append({"check": "ignore_rules_active", "ok": False, "detail": str(e)})
708
+
709
+ if args.json:
710
+ _print_json({"checks": checks})
711
+ return
712
+
713
+ table = Table(title="🩺 CodePulse Doctor", border_style="green")
714
+ table.add_column("Check", style="bold")
715
+ table.add_column("Status")
716
+ table.add_column("Detail", style="dim")
717
+ for c in checks:
718
+ status = "[bold green]OK[/bold green]" if c["ok"] else "[bold red]FAIL[/bold red]"
719
+ table.add_row(c["check"], status, c["detail"])
720
+ console.print(table)
721
+
722
+
723
+ def resolve_mcp_api_key(cli_value: str | None, env: Mapping[str, str] | None = None) -> str | None:
724
+ """
725
+ Resolves the raw API key `--mcp` scopes its connection to, per §13.
726
+
727
+ `--api-key` takes priority when given. Otherwise falls back to the
728
+ `CODEPULSE_MCP_API_KEY` environment variable -- added specifically so
729
+ a process SPAWNING `codepulse serve --mcp` (the VS Code extension's
730
+ Phase 3 MCP integration; any other MCP client launcher) never has to
731
+ put a secret directly into that child process's command-line
732
+ arguments, which are visible to other processes on the same machine
733
+ (`ps`/Task Manager, `/proc/[pid]/cmdline`) -- a real, avoidable
734
+ exposure `--api-key`-only would otherwise force on every caller.
735
+ """
736
+ if cli_value:
737
+ return cli_value
738
+ return (os.environ if env is None else env).get("CODEPULSE_MCP_API_KEY") or None
739
+
740
+
741
+ def cmd_serve(args: argparse.Namespace) -> None:
742
+ """Starts the local REST API (default), or the MCP server over stdio with --mcp (§22: "start local API/MCP server")."""
743
+ if args.mcp:
744
+ api_key = resolve_mcp_api_key(args.api_key)
745
+ if not api_key:
746
+ _stderr_console.print(
747
+ "[bold red]Error:[/bold red] --api-key (or the CODEPULSE_MCP_API_KEY environment "
748
+ "variable) is required for --mcp (MCP connections are scoped to repositories at "
749
+ "connection-setup time, §13)."
750
+ )
751
+ sys.exit(1)
752
+ import asyncio
753
+ from codepulse.config import get_settings
754
+ from codepulse.embeddings.embedder import CodeEmbedder as _Embedder
755
+ from codepulse.mcp_server import MCPAuthenticationError, build_mcp_server
756
+
757
+ settings = get_settings()
758
+ storage = StorageManager(db_url=settings.db_url)
759
+ engine = IndexingEngine(storage=storage, embedder=_Embedder())
760
+ try:
761
+ mcp = build_mcp_server(storage, engine, api_key)
762
+ except MCPAuthenticationError as e:
763
+ _stderr_console.print(f"[bold red]Error:[/bold red] {e}")
764
+ sys.exit(1)
765
+ # Load the embedding model on THIS (main) thread before serving.
766
+ # Left lazy, the first semantic/hybrid `search_code` call imports
767
+ # sentence-transformers from the stdio server's tool-call worker
768
+ # thread, which hangs on Windows (seen live: >240s vs 19s here).
769
+ # If it cannot load, still serve -- keyword search works and the
770
+ # semantic path reports EmbeddingUnavailableError explicitly (no
771
+ # silent pseudo-embedding fallback).
772
+ from codepulse.embeddings.embedder import EmbeddingUnavailableError
773
+ try:
774
+ engine.embedder.preload()
775
+ except EmbeddingUnavailableError as e:
776
+ _stderr_console.print(
777
+ f"[bold yellow]Warning:[/bold yellow] embedding model unavailable ({e}); "
778
+ "semantic and hybrid search will return errors, keyword search still works."
779
+ )
780
+ # stdout is the MCP protocol channel: status goes to stderr only.
781
+ _stderr_console.print("[bold green]✓[/bold green] MCP server starting (stdio transport)...")
782
+ asyncio.run(mcp.run_stdio_async())
783
+ return
784
+
785
+ import uvicorn
786
+ console.print(f"[bold green]✓[/bold green] Starting CodePulse API on http://{args.host}:{args.port}")
787
+ uvicorn.run("codepulse.api.app:app", host=args.host, port=args.port)
788
+
789
+
790
+ def main() -> None:
791
+ parser = argparse.ArgumentParser(
792
+ prog="codepulse",
793
+ description="CodePulse: Semantic Codebase Intelligence Engine",
794
+ )
795
+ parser.add_argument("--config", "-c", help="Path to codepulse.toml config file", default=None)
796
+ parser.add_argument("--db-url", help="Override database URL", default=None)
797
+ parser.add_argument("--embedding-model", help="Override embedding model name", default=None)
798
+
799
+ subparsers = parser.add_subparsers(dest="command", required=True)
800
+
801
+ # 1. Parse command
802
+ parse_p = subparsers.add_parser("parse", help="Parse a file and view AST symbols, complexity, and calls")
803
+ parse_p.add_argument("file_path", help="Path to source code file")
804
+
805
+ # 2. Search / Index command
806
+ search_p = subparsers.add_parser("search", help="Index and search a repository")
807
+ search_p.add_argument("path", help="Repository path to index")
808
+ search_p.add_argument("--query", "-q", help="Search query (natural language or code identifier)", default=None)
809
+ search_p.add_argument("--type", "-t", choices=["hybrid", "keyword", "semantic"], default="hybrid")
810
+ search_p.add_argument("--limit", "-l", type=int, default=10)
811
+ search_p.add_argument("--json", action="store_true", help="Emit raw JSON instead of a formatted table (requires --query)")
812
+
813
+ # 3. Impact analysis command
814
+ impact_p = subparsers.add_parser("impact", help="Analyze call graph impact and blast radius")
815
+ impact_p.add_argument("path", help="Repository path")
816
+ impact_p.add_argument("symbol", help="Symbol name to analyze")
817
+ impact_p.add_argument("--depth", "-d", type=int, default=3)
818
+ impact_p.add_argument("--json", action="store_true", help="Emit raw JSON instead of a formatted tree")
819
+
820
+ # 4. init
821
+ init_p = subparsers.add_parser("init", help="Create a local codepulse.toml config -- no infra required")
822
+ init_p.add_argument("path", nargs="?", default=".", help="Directory to create codepulse.toml in (default: cwd)")
823
+ init_p.add_argument("--force", action="store_true", help="Overwrite an existing codepulse.toml")
824
+
825
+ # 5. index
826
+ index_p = subparsers.add_parser("index", help="Full index of a repository")
827
+ index_p.add_argument("path", help="Repository path")
828
+ index_p.add_argument("--json", action="store_true")
829
+
830
+ # 6. update
831
+ update_p = subparsers.add_parser("update", help="Incremental (git-diff based when available) re-index")
832
+ update_p.add_argument("path", help="Repository path")
833
+ update_p.add_argument("--json", action="store_true")
834
+
835
+ # 7. symbol
836
+ symbol_p = subparsers.add_parser("symbol", help="Look up a symbol's definition")
837
+ symbol_p.add_argument("path", help="Repository path")
838
+ symbol_p.add_argument("symbol", help="Symbol name")
839
+ symbol_p.add_argument("--json", action="store_true")
840
+
841
+ # 8. refs
842
+ refs_p = subparsers.add_parser("refs", help="Direct references (callers + direct subclasses) of a symbol")
843
+ refs_p.add_argument("path", help="Repository path")
844
+ refs_p.add_argument("symbol", help="Symbol name")
845
+ refs_p.add_argument("--json", action="store_true")
846
+
847
+ # 9. callers
848
+ callers_p = subparsers.add_parser("callers", help="Callers of a symbol, direct and transitive")
849
+ callers_p.add_argument("path", help="Repository path")
850
+ callers_p.add_argument("symbol", help="Symbol name")
851
+ callers_p.add_argument("--depth", "-d", type=int, default=3)
852
+ callers_p.add_argument("--json", action="store_true")
853
+
854
+ # 10. callees
855
+ callees_p = subparsers.add_parser("callees", help="Symbols called by a symbol, direct and transitive")
856
+ callees_p.add_argument("path", help="Repository path")
857
+ callees_p.add_argument("symbol", help="Symbol name")
858
+ callees_p.add_argument("--depth", "-d", type=int, default=3)
859
+ callees_p.add_argument("--json", action="store_true")
860
+
861
+ # 11. similar
862
+ similar_p = subparsers.add_parser("similar", help="Semantically similar symbols")
863
+ similar_p.add_argument("path", help="Repository path")
864
+ similar_p.add_argument("symbol", help="Symbol name")
865
+ similar_p.add_argument("--threshold", type=float, default=0.75)
866
+ similar_p.add_argument("--limit", "-l", type=int, default=10)
867
+ similar_p.add_argument("--json", action="store_true")
868
+
869
+ # 12. status
870
+ status_p = subparsers.add_parser("status", help="Index freshness for a repository")
871
+ status_p.add_argument("path", help="Repository path")
872
+ status_p.add_argument("--json", action="store_true")
873
+
874
+ # 12b. modules
875
+ modules_p = subparsers.add_parser("modules", help="Module clusters + inbound/outbound degree rankings")
876
+ modules_p.add_argument("path", help="Repository path")
877
+ modules_p.add_argument("--json", action="store_true")
878
+
879
+ # 13. doctor
880
+ doctor_p = subparsers.add_parser("doctor", help="Environment/config diagnostics")
881
+ doctor_p.add_argument("path", nargs="?", default=None, help="Directory to check ignore rules against (default: cwd)")
882
+ doctor_p.add_argument("--json", action="store_true")
883
+
884
+ # 14. serve
885
+ migrate_p = subparsers.add_parser(
886
+ "migrate",
887
+ help="Bring the database up to the current schema (backs it up first; refuses anything it cannot do safely)",
888
+ )
889
+ migrate_p.add_argument("--check", action="store_true", help="Only report; change nothing. Exit 0 if up to date, 1 if it needs migrating, 2 if it cannot be migrated safely")
890
+ migrate_p.add_argument("--no-backup", action="store_true", help="Do not copy a SQLite database before changing it")
891
+ migrate_p.add_argument("--json", action="store_true")
892
+
893
+ from codepulse.auth import VALID_ROLES
894
+
895
+ key_p = subparsers.add_parser("key", help="Manage API keys (this is where you create your first one)")
896
+ key_sub = key_p.add_subparsers(dest="key_command", required=True)
897
+ key_create_p = key_sub.add_parser(
898
+ "create",
899
+ help="Create an API key scoped to one or more folders (registers a folder that is not known yet)",
900
+ )
901
+ key_create_p.add_argument(
902
+ "--repo", action="append", required=True, metavar="PATH",
903
+ help="A project folder the key may access (repeatable; at least one: a key with no folder can do nothing)",
904
+ )
905
+ key_create_p.add_argument("--name", default="codepulse", help="A label for the key (default: codepulse)")
906
+ key_create_p.add_argument(
907
+ "--role", choices=list(VALID_ROLES), default="indexer",
908
+ help="viewer = search and read; indexer = viewer + index; admin = everything incl. managing keys (default: indexer)",
909
+ )
910
+ key_create_p.add_argument("--json", action="store_true", help="Print the key and what it covers as JSON")
911
+
912
+ serve_p = subparsers.add_parser("serve", help="Start the local API server (or the MCP server with --mcp)")
913
+ serve_p.add_argument("--host", default="127.0.0.1")
914
+ serve_p.add_argument("--port", type=int, default=8000)
915
+ serve_p.add_argument("--mcp", action="store_true", help="Start the MCP server (stdio transport) instead of the REST API")
916
+ serve_p.add_argument(
917
+ "--api-key",
918
+ default=None,
919
+ help=(
920
+ "Raw API key to scope the MCP connection to (required with --mcp, unless "
921
+ "CODEPULSE_MCP_API_KEY is set in the environment instead -- preferred for "
922
+ "spawned/managed processes, since CLI args are visible to other processes "
923
+ "on the same machine)"
924
+ ),
925
+ )
926
+
927
+ args = parser.parse_args()
928
+
929
+ # Initialize configuration with CLI overrides
930
+ cli_overrides = {}
931
+ if args.db_url:
932
+ cli_overrides["db_url"] = args.db_url
933
+ if args.embedding_model:
934
+ cli_overrides["embedding_model"] = args.embedding_model
935
+
936
+ from codepulse.config import get_settings
937
+ settings = get_settings(config_file=args.config, cli_overrides=cli_overrides, reload=True)
938
+
939
+ # Backend-hardening addition (not a roadmap sprint item), found by the
940
+ # 2026-10-02 audit: every command below that takes a repository path
941
+ # used to accept a nonexistent one, exit 0 with "0 found", and leave
942
+ # an empty phantom repository (and possibly a brand-new database file)
943
+ # behind. Validated here, BEFORE the database diagnostic and before
944
+ # anything can create a database. `parse` (a file), `init` (creates its
945
+ # directory), `doctor` (optional) and `serve` (no path) are exempt.
946
+ missing_path = None
947
+ if args.command in _REPOSITORY_PATH_COMMANDS and not os.path.isdir(args.path):
948
+ missing_path = args.path
949
+ elif args.command == "key" and args.key_command == "create":
950
+ missing_path = next((p for p in args.repo if not os.path.isdir(p)), None)
951
+ if missing_path is not None:
952
+ message = f"Path '{missing_path}' is not an existing directory"
953
+ if getattr(args, "json", False):
954
+ _print_json({"error": "path_not_found", "message": message})
955
+ else:
956
+ _stderr_console.print(f"[bold red]Error:[/bold red] {message}")
957
+ sys.exit(1)
958
+
959
+ # Backend-hardening addition (not a roadmap sprint item): db_url
960
+ # defaults to "sqlite:///codepulse.db", resolved relative to the
961
+ # CURRENT WORKING DIRECTORY -- with zero prior feedback about which
962
+ # actual file that is. Running the same command from two different
963
+ # directories has always silently used two different databases (no
964
+ # error, no warning), a real footgun this discloses up front rather
965
+ # than leaving a user to discover it only once results look wrong.
966
+ # Skipped for `parse` and `init`, which never touch a database at all.
967
+ if args.command not in ("parse", "init"):
968
+ resolved_db = _display_db_path(settings.db_url)
969
+ db_file = resolved_db[len("sqlite:///"):] if resolved_db.startswith("sqlite:///") else None
970
+ # A resolved path is genuinely more alarming when the file
971
+ # doesn't exist yet -- that means THIS command is about to
972
+ # silently create a brand-new, empty database (the exact
973
+ # "wrong directory" mistake this whole diagnostic exists to
974
+ # catch), not just reuse one that already has prior work in
975
+ # it. A quiet path-only line doesn't mean much to a user who
976
+ # has no reason yet to suspect anything is wrong; "a new,
977
+ # empty database is being created" does.
978
+ if db_file is not None and not os.path.exists(db_file):
979
+ _stderr_console.print(
980
+ f"[yellow]⚠ No existing database found -- creating a NEW, empty one at:[/yellow]\n"
981
+ f"[yellow] {resolved_db}[/yellow]\n"
982
+ f"[dim]CodePulse keeps one database per user by default. If you expected to reuse an existing "
983
+ f"one, set --db-url / CODEPULSE_DB_URL, or run this from the directory that holds your "
984
+ f"codepulse.db.[/dim]"
985
+ )
986
+ else:
987
+ _stderr_console.print(f"[dim]Using database: {resolved_db}[/dim]")
988
+ if args.command != "migrate":
989
+ try:
990
+ from codepulse.storage.migrations import get_schema_status
991
+
992
+ warning = _schema_warning(get_schema_status(settings.db_url))
993
+ except Exception:
994
+ warning = None # never let a status lookup get in the way of the command itself
995
+ if warning:
996
+ _stderr_console.print(f"[yellow]⚠ {warning}[/yellow]")
997
+
998
+ try:
999
+ _dispatch(args)
1000
+ except Exception as exc:
1001
+ from codepulse.indexer.engine import IndexingPersistenceError
1002
+
1003
+ if not isinstance(exc, IndexingPersistenceError):
1004
+ raise
1005
+ message = f"{exc}. Nothing from this run was saved; the database is unchanged."
1006
+ if getattr(args, "json", False):
1007
+ _print_json({"error": "indexing_failed", "message": message})
1008
+ else:
1009
+ _stderr_console.print(f"[bold red]Error:[/bold red] {message}")
1010
+ sys.exit(1)
1011
+
1012
+
1013
+ def _dispatch(args: argparse.Namespace) -> None:
1014
+ if args.command == "parse":
1015
+ cmd_parse(args)
1016
+ elif args.command == "search":
1017
+ cmd_index_and_search(args)
1018
+ elif args.command == "impact":
1019
+ cmd_impact(args)
1020
+ elif args.command == "init":
1021
+ cmd_init(args)
1022
+ elif args.command == "index":
1023
+ cmd_index(args)
1024
+ elif args.command == "update":
1025
+ cmd_update(args)
1026
+ elif args.command == "symbol":
1027
+ cmd_symbol(args)
1028
+ elif args.command == "refs":
1029
+ cmd_refs(args)
1030
+ elif args.command == "callers":
1031
+ cmd_callers(args)
1032
+ elif args.command == "callees":
1033
+ cmd_callees(args)
1034
+ elif args.command == "similar":
1035
+ cmd_similar(args)
1036
+ elif args.command == "status":
1037
+ cmd_status(args)
1038
+ elif args.command == "modules":
1039
+ cmd_modules(args)
1040
+ elif args.command == "doctor":
1041
+ cmd_doctor(args)
1042
+ elif args.command == "migrate":
1043
+ cmd_migrate(args)
1044
+ elif args.command == "key":
1045
+ if args.key_command == "create":
1046
+ cmd_key_create(args)
1047
+ elif args.command == "serve":
1048
+ cmd_serve(args)
1049
+
1050
+
1051
+ if __name__ == "__main__":
1052
+ main()