indexter 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
indexter/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Indexter: CLI tool and MCP server for enhanced codebase context via RAG."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from .models import Repo
6
+
7
+ __all__ = ["Repo"]
8
+
9
+ __version__ = version("indexter")
@@ -0,0 +1,5 @@
1
+ """CLI module for indexter."""
2
+
3
+ from .cli import app
4
+
5
+ __all__ = ["app"]
indexter/cli/cli.py ADDED
@@ -0,0 +1,450 @@
1
+ """
2
+ Main CLI application and command definitions.
3
+
4
+ This module provides the main entry point for the Indexter CLI application,
5
+ which enables semantic code context retrieval for AI agents via RAG (Retrieval
6
+ Augmented Generation). It includes commands for initializing repositories,
7
+ indexing code, searching indexed content, and managing repository status.
8
+
9
+ The CLI is built using Typer for command-line parsing and Rich for enhanced
10
+ terminal output with colors, tables, and progress indicators.
11
+
12
+ Typical usage:
13
+ $ indexter init --path ~/code/repo-name
14
+ $ indexter index repo-name
15
+ $ indexter search "query" repo-name
16
+ $ indexter status
17
+ $ indexter forget repo-name
18
+ $ indexter config show
19
+ $ indexter config path
20
+ """
21
+
22
+ # NOTE: This file uses 'cast' from typing to help with type checking of anyio.run()
23
+ # anyio.run() is typed as returning T | None, but in practice, the functions it calls
24
+ # always return a value of type T or raise an exception. To satisfy the type checker, we use
25
+ # cast() to assert the expected return type. This does not affect runtime behavior, only
26
+ # static type checking.
27
+ #
28
+ # e.g.:
29
+ # repo = cast(Repo, anyio.run(Repo.init, repo_path.resolve()))
30
+ #
31
+ # This tells the type checker that we expect Repo.init to return a Repo instance.
32
+
33
+ import logging
34
+ from pathlib import Path
35
+ from typing import Annotated, cast
36
+
37
+ import anyio
38
+ import typer
39
+ from rich.console import Console
40
+ from rich.logging import RichHandler
41
+ from rich.progress import Progress, SpinnerColumn, TextColumn
42
+ from rich.table import Table
43
+
44
+ from indexter import __version__
45
+ from indexter.exceptions import RepoExistsError, RepoNotFoundError
46
+ from indexter.models import Repo
47
+ from indexter.store import VectorStore
48
+ from indexter.store.models import IndexResult, SearchResults
49
+
50
+ from .config import config_app
51
+ from .store import store_app
52
+
53
+ app = typer.Typer(
54
+ name="indexter",
55
+ help="indexter - Enhanced codebase context for AI agents via RAG.",
56
+ no_args_is_help=True,
57
+ rich_markup_mode="rich",
58
+ )
59
+ app.add_typer(config_app, name="config")
60
+ app.add_typer(store_app, name="store")
61
+
62
+
63
+ console = Console()
64
+
65
+
66
+ def version_callback(value: bool) -> None:
67
+ """Print the application version and exit.
68
+
69
+ This callback is triggered when the --version flag is used. It displays
70
+ the current version of Indexter and exits the application.
71
+
72
+ Args:
73
+ value: If True, print the version and exit. If False, do nothing.
74
+
75
+ Raises:
76
+ typer.Exit: Always raised when value is True to exit the application.
77
+ """
78
+ if value:
79
+ console.print(f"indexter {__version__}")
80
+ raise typer.Exit()
81
+
82
+
83
+ @app.callback()
84
+ def main(
85
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Enable verbose output")] = False,
86
+ version: Annotated[
87
+ bool | None,
88
+ typer.Option("--version", callback=version_callback, is_eager=True, help="Show version"),
89
+ ] = None,
90
+ ) -> None:
91
+ """
92
+ Indexter - Semantic Code Context For Your LLM.
93
+
94
+ This is the main callback function for the CLI application. It sets up
95
+ logging configuration and handles global options like verbose output
96
+ and version display.
97
+
98
+ Args:
99
+ verbose: Enable verbose debug logging output. Defaults to False.
100
+ version: When provided, displays version and exits. This parameter
101
+ is handled by version_callback. Defaults to None.
102
+
103
+ Returns:
104
+ None: This function configures logging and does not return a value.
105
+ """
106
+ # Set up logging with rich handler
107
+ level = logging.DEBUG if verbose else logging.INFO
108
+ logging.basicConfig(
109
+ level=level,
110
+ format="%(message)s",
111
+ handlers=[RichHandler(console=console, show_time=False, show_path=False)],
112
+ )
113
+
114
+
115
+ @app.command()
116
+ def init(
117
+ path: Annotated[str, typer.Option("--path", "-p", help="Path to the git repository to index")] = ".",
118
+ no_index: Annotated[
119
+ bool,
120
+ typer.Option("--no-index", "-n", help="Do not index the repository after initialization", show_default=True),
121
+ ] = False,
122
+ ) -> None:
123
+ """
124
+ Initialize a git repository for indexing.
125
+
126
+ Registers a git repository with Indexter, preparing it for semantic indexing.
127
+ This command validates the repository path, creates necessary metadata, and
128
+ adds it to the list of managed repositories.
129
+
130
+ Args:
131
+ path: a string representing a filesystem path to the git repository to initialize.
132
+ The path will be resolved to an absolute path.
133
+ no_index: If True, do not index the repository after initialization.
134
+ Defaults to False.
135
+
136
+ Raises:
137
+ typer.Exit: Exits with code 1 if the repository already exists or if an
138
+ unexpected error occurs during initialization or indexing.
139
+
140
+ Examples:
141
+ $ indexter init /home/user/projects/myrepo
142
+ ✓ Added myrepo to indexter
143
+
144
+ Repository 'myrepo' initialized successfully and indexed successfully!
145
+
146
+ Next steps:
147
+ 1. Use indexter search 'your query' myrepo to search the indexed code.
148
+ """
149
+
150
+ async def _init() -> tuple[Repo, IndexResult | None]:
151
+ """Run all init operations in a single event loop."""
152
+ async with VectorStore() as store:
153
+ resolved_path = Path(path).resolve()
154
+ repo = await Repo.init(resolved_path)
155
+ if no_index:
156
+ return repo, None
157
+ # Call index after init
158
+ result = await repo.index(store)
159
+ return repo, result
160
+
161
+ try:
162
+ with Progress(
163
+ SpinnerColumn(),
164
+ TextColumn("[progress.description]{task.description}"),
165
+ console=console,
166
+ ) as progress:
167
+ task_description = "Initializing..." if no_index else "Initializing and indexing..."
168
+ progress.add_task(task_description, total=None)
169
+ repo, result = cast(tuple[Repo, IndexResult | None], anyio.run(_init))
170
+ console.print(f"[green]✓[/green] Added [bold]{repo.name}[/bold] to Indexter")
171
+ except RepoExistsError as e:
172
+ console.print(f"[red]✗[/red] {e}")
173
+ raise typer.Exit(1) from e
174
+ except Exception as e:
175
+ console.print(f"[red]✗[/red] Unexpected error: {e}")
176
+ raise typer.Exit(1) from e
177
+
178
+ if no_index:
179
+ # Show next steps
180
+ console.print()
181
+ console.print(f"[bold]Repository '{repo.name}' initialized successfully![/bold]")
182
+ console.print()
183
+ console.print("Next steps:")
184
+ console.print(f" 1. Run [bold]indexter index {repo.name}[/bold] to index the repository.")
185
+ console.print(f" 2. Use [bold]indexter search 'your query' {repo.name}[/bold] to search the indexed code.")
186
+ console.print()
187
+ else:
188
+ # Show next steps and index summary
189
+ console.print()
190
+ console.print(f"[bold]Repository '{repo.name}' initialized and indexed successfully![/bold]")
191
+ if result:
192
+ console.print(f" [green]✓[/green] {repo.name}: {result.summary}")
193
+ console.print()
194
+ console.print("Next steps:")
195
+ console.print(f" 1. Use [bold]indexter search 'your query' {repo.name}[/bold] to search the indexed code.")
196
+ console.print()
197
+
198
+
199
+ @app.command()
200
+ def index(
201
+ name: Annotated[str, typer.Argument(help="Name of the repository to index")],
202
+ full: Annotated[
203
+ bool,
204
+ typer.Option("--full", "-f", help="Force full re-indexing of the repository", show_default=True),
205
+ ] = False,
206
+ ) -> None:
207
+ """
208
+ Index a git repository in the vector store.
209
+
210
+ Performs semantic indexing of the specified git repository, storing code
211
+ snippets as vector embeddings for efficient retrieval.
212
+
213
+ By default, only changed documents are indexed for efficiency. Use --full to
214
+ force complete re-indexing of all documents.
215
+
216
+ The command tracks added, updated, and deleted nodes, and reports any
217
+ errors encountered during the indexing process.
218
+
219
+ Args:
220
+ name: Name of the repository to index. Must be a repository previously
221
+ initialized with 'indexter init'.
222
+ full: If True, forces full re-indexing of all documents in the repository,
223
+ ignoring incremental change detection. Defaults to False.
224
+
225
+ Raises:
226
+ typer.Exit: Exits with code 1 if the repository is not found or if an
227
+ unexpected error occurs.
228
+
229
+ Examples:
230
+ $ indexter index myrepo
231
+ ✓ myrepo: +15 ~3 -2 (5 documents synced) (1 documents deleted)
232
+ Indexing complete!
233
+
234
+ $ indexter index myrepo --full
235
+ ✓ myrepo: +150 ~0 -0 (50 documents synced) (0 documents deleted)
236
+ Indexing complete!
237
+ """
238
+
239
+ async def _index() -> tuple[Repo, IndexResult]:
240
+ """Run all index operations in a single event loop."""
241
+ async with VectorStore() as store:
242
+ repo = await Repo.get_one(name)
243
+ result = await repo.index(store, full)
244
+ return repo, result
245
+
246
+ try:
247
+ with Progress(
248
+ SpinnerColumn(),
249
+ TextColumn("[progress.description]{task.description}"),
250
+ console=console,
251
+ ) as progress:
252
+ progress.add_task("Indexing...", total=None)
253
+ repo, result = cast(tuple[Repo, IndexResult], anyio.run(_index))
254
+ except RepoNotFoundError as e:
255
+ console.print(f"[red]✗[/red] Repository not found: {name}")
256
+ console.print("Run 'indexter init <repo_path>' to initialize the repository first.")
257
+ raise typer.Exit(1) from e
258
+ except Exception as e:
259
+ console.print(f"[red]✗[/red] Unexpected error: {e}")
260
+ raise typer.Exit(1) from e
261
+
262
+ if result.documents_indexed == 0:
263
+ console.print(f" [dim]●[/dim] {repo.name}: up to date")
264
+ console.print(
265
+ f" [green]✓[/green] No changes detected. {result.documents_checked} documents checked. "
266
+ "Repository is up to date."
267
+ )
268
+ else:
269
+ console.print(f" [green]✓[/green] {repo.name}: {result.summary}")
270
+
271
+ if result.errors:
272
+ console.print(f" [yellow]Errors: {len(result.errors)}[/yellow]")
273
+ for error in result.errors[:5]:
274
+ console.print(f" - {error}")
275
+ if len(result.errors) > 5:
276
+ console.print(f" ... and {len(result.errors) - 5} more")
277
+ console.print(" [yellow]Some documents could not be indexed. Please check the errors above.[/yellow]")
278
+ return
279
+
280
+ if result.skipped_documents:
281
+ console.print(f" [yellow]Skipped: {result.skipped_documents} documents[/yellow]")
282
+ console.print(
283
+ " [yellow]Some documents skipped during indexing due to maximum allowed file limit "
284
+ "being exceeded.[/yellow]"
285
+ )
286
+
287
+ console.print("[green]Indexing complete![/green]")
288
+
289
+
290
+ @app.command()
291
+ def search(
292
+ query: Annotated[str, typer.Argument(help="Search query")],
293
+ name: Annotated[str, typer.Argument(help="Name of the repository to search")],
294
+ limit: Annotated[int, typer.Option("--limit", "-l", help="Number of results to return", show_default=True)] = 10,
295
+ ) -> None:
296
+ """Search indexed nodes in a repository.
297
+
298
+ Performs semantic search across the indexed codebase using vector similarity.
299
+ Returns the most relevant code snippets ranked by similarity score, displayed
300
+ in a formatted table with scores, content previews, and file paths.
301
+
302
+ Args:
303
+ query: Natural language search query describing the code you're looking for.
304
+ name: Name of the repository to search. Must be an indexed repository.
305
+ limit: Maximum number of search results to return. Defaults to 10.
306
+
307
+ Raises:
308
+ typer.Exit: Exits with code 1 if the repository is not found or if an
309
+ unexpected error occurs.
310
+
311
+ Examples:
312
+ $ indexter search "authentication middleware" myrepo
313
+ ┏━━━━━━━┳━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━┓
314
+ ┃ Score ┃ Content ┃ Document Path ┃
315
+ ┡━━━━━━━╇━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━┩
316
+ │ 0.856 │ def auth │ src/auth/mid... │
317
+ └───────┴──────────────────┴──────────────────┘
318
+
319
+ $ indexter search "error handling" myrepo --limit 5
320
+ """
321
+
322
+ async def _search() -> tuple[Repo, SearchResults]:
323
+ """Run all search operations in a single event loop."""
324
+ async with VectorStore() as store:
325
+ repo = await Repo.get_one(name)
326
+ results = await repo.search(query, store, limit=limit)
327
+ return repo, results
328
+
329
+ try:
330
+ repo, search_results = cast(tuple[Repo, SearchResults], anyio.run(_search))
331
+ except RepoNotFoundError as e:
332
+ console.print(f"[red]✗[/red] Repository not found: {name}")
333
+ raise typer.Exit(1) from e
334
+ except Exception as e:
335
+ console.print(f"[red]✗[/red] Unexpected error: {e}")
336
+ raise typer.Exit(1) from e
337
+
338
+ if not search_results.results:
339
+ console.print(f"[yellow]No results found for query:[/yellow] {query}")
340
+ return
341
+
342
+ table = Table(title=f"Search Results for '{query}' in '{repo.name}'")
343
+ table.add_column("Score", justify="right", style="cyan", no_wrap=True)
344
+ table.add_column("Content", style="magenta")
345
+ table.add_column("Document Path", style="green")
346
+
347
+ for result in search_results.results:
348
+ table.add_row(
349
+ f"{result.score:.4f}",
350
+ result.content.strip().replace("\n", " ")[:50] + "...",
351
+ str(result.metadata.get("document_path", "unknown")),
352
+ )
353
+
354
+ console.print(table)
355
+
356
+
357
+ @app.command()
358
+ def status() -> None:
359
+ """Show status of indexed repositories.
360
+
361
+ Displays a table of all repositories managed by Indexter, including their
362
+ paths, indexing statistics (number of nodes, documents), and current status.
363
+ This helps track which repositories are indexed and identify those needing
364
+ updates.
365
+
366
+ Returns:
367
+ None: This function prints a formatted table to the console and does
368
+ not return a value.
369
+
370
+ Examples:
371
+ $ indexter status
372
+ ┏━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━┳━━━━━━━━━━━┳━━━━━━━━━━━━━┓
373
+ ┃ Name ┃ Path ┃ Nodes ┃ Documents ┃ Stale ┃
374
+ ┡━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━╇━━━━━━━━━━━╇━━━━━━━━━━━━━┩
375
+ │ myrepo │ /home/user/... │ 1250 │ 45 │ True │
376
+ │ webapp │ /home/user/... │ 3420 │ 128 │ False │
377
+ └─────────┴────────────────┴───────┴───────────┴─────────────┘
378
+ """
379
+
380
+ async def _status() -> list[Repo]:
381
+ """Run all status operations in a single event loop."""
382
+ async with VectorStore() as store:
383
+ return await Repo.get_all(store, with_metadata=True)
384
+
385
+ repos = cast(list[Repo], anyio.run(_status))
386
+
387
+ if not repos:
388
+ console.print("[bold]Repositories[/bold]")
389
+ console.print(" No repositories indexed. Run 'indexter index <repo_path>' to index a repository.")
390
+ console.print()
391
+ return
392
+
393
+ table = Table(title="Indexed Repositories")
394
+ table.add_column("Name", style="cyan")
395
+ table.add_column("Path")
396
+ table.add_column("Nodes", justify="right")
397
+ table.add_column("Documents", justify="right")
398
+ table.add_column("Stale", justify="right")
399
+
400
+ for repo in repos:
401
+ table.add_row(
402
+ repo.name,
403
+ str(repo.path),
404
+ str(repo.metadata.nodes_indexed if repo.metadata else "-"),
405
+ str(repo.metadata.documents_indexed if repo.metadata else "-"),
406
+ str(repo.metadata.is_stale if repo.metadata else "-"),
407
+ )
408
+
409
+ console.print(table)
410
+ console.print()
411
+
412
+
413
+ @app.command()
414
+ def forget(
415
+ name: Annotated[str, typer.Argument(help="Name of the repository to forget")],
416
+ ) -> None:
417
+ """Forget a repository (remove from indexter and delete indexed data).
418
+
419
+ Removes a repository from Indexter's management and deletes all associated
420
+ indexed data from the vector store. This operation cannot be undone. The
421
+ original repository files remain unchanged.
422
+
423
+ Args:
424
+ name: Name of the repository to remove. Must be a previously initialized
425
+ repository.
426
+
427
+ Raises:
428
+ typer.Exit: Exits with code 1 if the repository is not found or if an
429
+ unexpected error occurs during removal.
430
+
431
+ Examples:
432
+ $ indexter forget myrepo
433
+ ✓ Repository 'myrepo' is forgotten.
434
+ """
435
+
436
+ async def _forget() -> None:
437
+ """Run all forget operations in a single event loop."""
438
+ async with VectorStore() as store:
439
+ await Repo.remove_one(name, store)
440
+
441
+ try:
442
+ anyio.run(_forget)
443
+ except RepoNotFoundError as e:
444
+ console.print(f"[red]✗[/red] Repository not found: {name}")
445
+ raise typer.Exit(1) from e
446
+ except Exception as e:
447
+ console.print(f"[red]✗[/red] Unexpected error: {e}")
448
+ raise typer.Exit(1) from e
449
+ else:
450
+ console.print(f"[green]✓[/green] Repository '{name}' is forgotten.")
indexter/cli/config.py ADDED
@@ -0,0 +1,77 @@
1
+ """
2
+ Configuration CLI commands.
3
+
4
+ This module provides CLI commands for viewing Indexter's global
5
+ configuration settings. It includes commands to display the configuration file
6
+ contents and retrieve the configuration file path.
7
+ """
8
+
9
+ import typer
10
+ from rich.console import Console
11
+ from rich.syntax import Syntax
12
+
13
+ from indexter.config import settings
14
+
15
+ config_app = typer.Typer(
16
+ name="config",
17
+ help="View Indexter global settings.",
18
+ no_args_is_help=True,
19
+ )
20
+
21
+ console = Console()
22
+
23
+
24
+ @config_app.command(name="show")
25
+ def config_show() -> None:
26
+ """Show Indexter global settings config.
27
+
28
+ Displays the Indexter configuration file path and its contents in a
29
+ formatted view. The configuration file is displayed with syntax
30
+ highlighting using the Monokai theme. If the configuration file
31
+ does not exist, a message is displayed indicating this.
32
+
33
+ Returns:
34
+ None: This function prints output to the console and does not
35
+ return a value.
36
+
37
+ Examples:
38
+ $ indexter config show
39
+ Indexter Settings
40
+ Config file: /home/user/.config/indexter/config.toml
41
+
42
+ [config file contents with syntax highlighting]
43
+ """
44
+ console.print("[bold]Indexter Settings[/bold]")
45
+ console.print(f" Config file: {str(settings.config_file)}", overflow="ignore", crop=False)
46
+ console.print()
47
+
48
+ if settings.config_file.exists():
49
+ content = settings.config_file.read_text()
50
+ syntax = Syntax(content, "toml", theme="monokai", line_numbers=True)
51
+ console.print(syntax)
52
+ else:
53
+ console.print("[dim]Config file not found.[/dim]")
54
+
55
+
56
+ @config_app.command(name="path")
57
+ def config_path() -> None:
58
+ """Print the path to the Indexter settings config file.
59
+
60
+ Outputs the absolute file system path to the Indexter configuration
61
+ file. This uses plain print() instead of Rich's console.print() to
62
+ avoid any formatting or text wrapping, making the output suitable
63
+ for use in scripts or command substitution.
64
+
65
+ Returns:
66
+ None: This function prints output to stdout and does not return
67
+ a value.
68
+
69
+ Examples:
70
+ $ indexter config path
71
+ /home/user/.config/indexter/config.toml
72
+
73
+ # Use in shell scripts
74
+ $ cat $(indexter config path)
75
+ """
76
+ # Use print instead of console.print to avoid Rich formatting/wrapping
77
+ print(settings.config_file)