kodit 0.1.4__tar.gz → 0.1.6__tar.gz

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.

Potentially problematic release.


This version of kodit might be problematic. Click here for more details.

Files changed (87) hide show
  1. {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/pypi.yaml +0 -1
  2. {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/test.yaml +6 -0
  3. kodit-0.1.6/.vscode/launch.json +15 -0
  4. {kodit-0.1.4 → kodit-0.1.6}/.vscode/settings.json +1 -1
  5. {kodit-0.1.4 → kodit-0.1.6}/PKG-INFO +6 -2
  6. {kodit-0.1.4 → kodit-0.1.6}/docs/_index.md +2 -1
  7. {kodit-0.1.4 → kodit-0.1.6}/pyproject.toml +6 -2
  8. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/_version.py +2 -2
  9. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/env.py +5 -4
  10. kodit-0.1.6/src/kodit/app.py +29 -0
  11. kodit-0.1.6/src/kodit/bm25/__init__.py +1 -0
  12. kodit-0.1.6/src/kodit/bm25/bm25.py +71 -0
  13. kodit-0.1.6/src/kodit/cli.py +272 -0
  14. kodit-0.1.6/src/kodit/config.py +97 -0
  15. kodit-0.1.6/src/kodit/database.py +75 -0
  16. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/indexing/repository.py +11 -0
  17. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/indexing/service.py +28 -16
  18. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/logging.py +20 -18
  19. kodit-0.1.6/src/kodit/mcp.py +160 -0
  20. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/middleware.py +16 -0
  21. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/retreival/repository.py +32 -0
  22. kodit-0.1.6/src/kodit/retreival/service.py +69 -0
  23. kodit-0.1.6/src/kodit/snippets/__init__.py +1 -0
  24. kodit-0.1.6/src/kodit/snippets/languages/__init__.py +53 -0
  25. kodit-0.1.6/src/kodit/snippets/languages/csharp.scm +12 -0
  26. kodit-0.1.6/src/kodit/snippets/languages/python.scm +22 -0
  27. kodit-0.1.6/src/kodit/snippets/method_snippets.py +120 -0
  28. kodit-0.1.6/src/kodit/snippets/snippets.py +48 -0
  29. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/sources/service.py +3 -5
  30. {kodit-0.1.4 → kodit-0.1.6}/tests/conftest.py +18 -0
  31. kodit-0.1.6/tests/kodit/cli_test.py +75 -0
  32. kodit-0.1.6/tests/kodit/e2e.py +145 -0
  33. {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/indexing/test_service.py +11 -6
  34. kodit-0.1.6/tests/kodit/mcp_test.py +41 -0
  35. {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/retreival/test_service.py +28 -6
  36. kodit-0.1.6/tests/kodit/snippets/__init__.py +0 -0
  37. kodit-0.1.6/tests/kodit/snippets/csharp.cs +44 -0
  38. kodit-0.1.6/tests/kodit/snippets/detect_language_test.py +87 -0
  39. kodit-0.1.6/tests/kodit/snippets/method_extraction_test.py +108 -0
  40. kodit-0.1.6/tests/kodit/snippets/python.py +24 -0
  41. {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/sources/test_service.py +2 -2
  42. kodit-0.1.6/tests/smoke.sh +40 -0
  43. {kodit-0.1.4 → kodit-0.1.6}/uv.lock +428 -92
  44. kodit-0.1.4/src/kodit/app.py +0 -25
  45. kodit-0.1.4/src/kodit/cli.py +0 -186
  46. kodit-0.1.4/src/kodit/config.py +0 -5
  47. kodit-0.1.4/src/kodit/database.py +0 -91
  48. kodit-0.1.4/src/kodit/mcp.py +0 -110
  49. kodit-0.1.4/src/kodit/retreival/service.py +0 -30
  50. kodit-0.1.4/src/kodit/sse.py +0 -61
  51. kodit-0.1.4/tests/kodit/cli_test.py +0 -19
  52. kodit-0.1.4/tests/kodit/mcp_test.py +0 -109
  53. kodit-0.1.4/tests/smoke.sh +0 -20
  54. {kodit-0.1.4 → kodit-0.1.6}/.cursor/rules/kodit.mdc +0 -0
  55. {kodit-0.1.4 → kodit-0.1.6}/.github/CODE_OF_CONDUCT.md +0 -0
  56. {kodit-0.1.4 → kodit-0.1.6}/.github/CONTRIBUTING.md +0 -0
  57. {kodit-0.1.4 → kodit-0.1.6}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  58. {kodit-0.1.4 → kodit-0.1.6}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  59. {kodit-0.1.4 → kodit-0.1.6}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  60. {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/docker.yaml +0 -0
  61. {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/docs.yaml +0 -0
  62. {kodit-0.1.4 → kodit-0.1.6}/.github/workflows/pypi-test.yaml +0 -0
  63. {kodit-0.1.4 → kodit-0.1.6}/.gitignore +0 -0
  64. {kodit-0.1.4 → kodit-0.1.6}/.python-version +0 -0
  65. {kodit-0.1.4 → kodit-0.1.6}/Dockerfile +0 -0
  66. {kodit-0.1.4 → kodit-0.1.6}/LICENSE +0 -0
  67. {kodit-0.1.4 → kodit-0.1.6}/README.md +0 -0
  68. {kodit-0.1.4 → kodit-0.1.6}/alembic.ini +0 -0
  69. {kodit-0.1.4 → kodit-0.1.6}/docs/developer/index.md +0 -0
  70. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/.gitignore +0 -0
  71. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/__init__.py +0 -0
  72. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/README +0 -0
  73. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/__init__.py +0 -0
  74. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/script.py.mako +0 -0
  75. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/versions/85155663351e_initial.py +0 -0
  76. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/alembic/versions/__init__.py +0 -0
  77. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/indexing/__init__.py +0 -0
  78. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/indexing/models.py +0 -0
  79. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/retreival/__init__.py +0 -0
  80. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/sources/__init__.py +0 -0
  81. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/sources/models.py +0 -0
  82. {kodit-0.1.4 → kodit-0.1.6}/src/kodit/sources/repository.py +0 -0
  83. {kodit-0.1.4 → kodit-0.1.6}/tests/__init__.py +0 -0
  84. {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/__init__.py +0 -0
  85. {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/indexing/__init__.py +0 -0
  86. {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/retreival/__init__.py +0 -0
  87. {kodit-0.1.4 → kodit-0.1.6}/tests/kodit/sources/__init__.py +0 -0
@@ -32,7 +32,6 @@ jobs:
32
32
 
33
33
  REPO_NAME=${{ github.event.repository.name }}
34
34
  REPO_TAG=${{ github.event.release.tag_name }}
35
- REPO_TAG=0.1.3
36
35
 
37
36
  # Get the first letter of the repo name
38
37
  REPO_NAME_FIRST_LETTER=${REPO_NAME:0:1}
@@ -14,6 +14,7 @@ permissions:
14
14
  jobs:
15
15
  test:
16
16
  runs-on: ubuntu-latest
17
+ timeout-minutes: 10
17
18
  steps:
18
19
  - name: Checkout code
19
20
  uses: actions/checkout@v4
@@ -44,6 +45,7 @@ jobs:
44
45
 
45
46
  build-package:
46
47
  runs-on: ubuntu-latest
48
+ timeout-minutes: 10
47
49
  steps:
48
50
  - name: Checkout code
49
51
  uses: actions/checkout@v4
@@ -67,6 +69,7 @@ jobs:
67
69
  test-package:
68
70
  needs: build-package
69
71
  runs-on: ubuntu-latest
72
+ timeout-minutes: 10
70
73
  steps:
71
74
  - uses: actions/checkout@v4
72
75
  with:
@@ -97,5 +100,8 @@ jobs:
97
100
  - name: Run simple version command test
98
101
  run: kodit version
99
102
 
103
+ - name: Delete kodit data_dir
104
+ run: rm -rf ${HOME}/.kodit
105
+
100
106
  - name: Run smoke test
101
107
  run: ./tests/smoke.sh
@@ -0,0 +1,15 @@
1
+ {
2
+ "version": "0.2.0",
3
+ "configurations": [
4
+ {
5
+ "name": "Python Debugger: Module",
6
+ "type": "debugpy",
7
+ "request": "launch",
8
+ "module": "src.kodit.cli",
9
+ "args": [
10
+ "retrieve",
11
+ "hello",
12
+ ]
13
+ }
14
+ ]
15
+ }
@@ -24,5 +24,5 @@
24
24
  "python.analysis.autoImportCompletions": true,
25
25
  "python.analysis.completeFunctionParens": true,
26
26
  "python.analysis.inlayHints.functionReturnTypes": true,
27
- "python.analysis.typeCheckingMode": "strict",
27
+ "python.analysis.typeCheckingMode": "standard",
28
28
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kodit
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Code indexing for better AI code generation
5
5
  Project-URL: Homepage, https://docs.helixml.tech/kodit/
6
6
  Project-URL: Documentation, https://docs.helixml.tech/kodit/
@@ -22,18 +22,22 @@ Requires-Dist: aiosqlite>=0.20.0
22
22
  Requires-Dist: alembic>=1.15.2
23
23
  Requires-Dist: asgi-correlation-id>=4.3.4
24
24
  Requires-Dist: better-exceptions>=0.3.3
25
+ Requires-Dist: bm25s[core]>=0.2.12
25
26
  Requires-Dist: click>=8.1.8
26
27
  Requires-Dist: colorama>=0.4.6
27
28
  Requires-Dist: dotenv>=0.9.9
28
29
  Requires-Dist: fastapi[standard]>=0.115.12
30
+ Requires-Dist: fastmcp>=2.3.3
29
31
  Requires-Dist: httpx-retries>=0.3.2
30
32
  Requires-Dist: httpx>=0.28.1
31
- Requires-Dist: mcp>=1.6.0
32
33
  Requires-Dist: posthog>=4.0.1
34
+ Requires-Dist: pydantic-settings>=2.9.1
33
35
  Requires-Dist: pytable-formatter>=0.1.1
34
36
  Requires-Dist: sqlalchemy[asyncio]>=2.0.40
35
37
  Requires-Dist: structlog>=25.3.0
36
38
  Requires-Dist: tdqm>=0.0.1
39
+ Requires-Dist: tree-sitter-language-pack>=0.7.3
40
+ Requires-Dist: tree-sitter>=0.24.0
37
41
  Requires-Dist: uritools>=5.0.0
38
42
  Description-Content-Type: text/markdown
39
43
 
@@ -87,6 +87,7 @@ description:
87
87
  globs:
88
88
  alwaysApply: true
89
89
  ---
90
- You MUST use the code-search MCP tool and always include any file context the user has provided in the chat.```
90
+ You MUST use the code-search MCP tool and always include any file context the user has provided in the chat.
91
+ ```
91
92
 
92
93
  Alternatively, you can browse to the cursor settings and set this prompt globally.
@@ -29,7 +29,6 @@ dependencies = [
29
29
  "fastapi[standard]>=0.115.12",
30
30
  "httpx-retries>=0.3.2",
31
31
  "httpx>=0.28.1",
32
- "mcp>=1.6.0",
33
32
  "structlog>=25.3.0",
34
33
  "posthog>=4.0.1",
35
34
  "sqlalchemy[asyncio]>=2.0.40",
@@ -39,6 +38,11 @@ dependencies = [
39
38
  "aiofiles>=24.1.0",
40
39
  "tdqm>=0.0.1",
41
40
  "uritools>=5.0.0",
41
+ "tree-sitter-language-pack>=0.7.3",
42
+ "tree-sitter>=0.24.0",
43
+ "fastmcp>=2.3.3",
44
+ "pydantic-settings>=2.9.1",
45
+ "bm25s[core]>=0.2.12",
42
46
  ]
43
47
 
44
48
  [dependency-groups]
@@ -104,7 +108,7 @@ ignore = [
104
108
  "PGH004", # If I've disabled all, I mean disable all
105
109
  ]
106
110
  select = ["ALL"]
107
- exclude = []
111
+ exclude = ["./tests/*"]
108
112
 
109
113
  [[tool.uv.index]]
110
114
  name = "pypi"
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.1.4'
21
- __version_tuple__ = version_tuple = (0, 1, 4)
20
+ __version__ = version = '0.1.6'
21
+ __version_tuple__ = version_tuple = (0, 1, 6)
@@ -3,7 +3,6 @@
3
3
 
4
4
  import asyncio
5
5
 
6
- import structlog
7
6
  from alembic import context
8
7
  from sqlalchemy import pool
9
8
  from sqlalchemy.engine import Connection
@@ -66,8 +65,6 @@ async def run_async_migrations() -> None:
66
65
  prefix="sqlalchemy.",
67
66
  poolclass=pool.NullPool,
68
67
  )
69
- log = structlog.get_logger(__name__)
70
- log.debug("Running migrations on %s", connectable.url)
71
68
 
72
69
  async with connectable.connect() as connection:
73
70
  await connection.run_sync(do_run_migrations)
@@ -77,7 +74,11 @@ async def run_async_migrations() -> None:
77
74
 
78
75
  def run_migrations_online() -> None:
79
76
  """Run migrations in 'online' mode."""
80
- asyncio.run(run_async_migrations())
77
+ connectable = config.attributes.get("connection", None)
78
+ if connectable is None:
79
+ asyncio.run(run_async_migrations())
80
+ else:
81
+ do_run_migrations(connectable)
81
82
 
82
83
 
83
84
  if context.is_offline_mode():
@@ -0,0 +1,29 @@
1
+ """FastAPI application for kodit API."""
2
+
3
+ from asgi_correlation_id import CorrelationIdMiddleware
4
+ from fastapi import FastAPI
5
+
6
+ from kodit.mcp import mcp
7
+ from kodit.middleware import ASGICancelledErrorMiddleware, logging_middleware
8
+
9
+ # See https://gofastmcp.com/deployment/asgi#fastapi-integration
10
+ mcp_app = mcp.sse_app()
11
+ app = FastAPI(title="kodit API", lifespan=mcp_app.router.lifespan_context)
12
+
13
+ # Add middleware
14
+ app.middleware("http")(logging_middleware)
15
+ app.add_middleware(CorrelationIdMiddleware)
16
+
17
+
18
+ @app.get("/")
19
+ async def root() -> dict[str, str]:
20
+ """Return a welcome message for the kodit API."""
21
+ return {"message": "Hello, World!"}
22
+
23
+
24
+ # Add mcp routes last, otherwise previous routes aren't added
25
+ app.mount("", mcp_app)
26
+
27
+ # Wrap the entire app with ASGI middleware after all routes are added to suppress
28
+ # CancelledError at the ASGI level
29
+ app = ASGICancelledErrorMiddleware(app)
@@ -0,0 +1 @@
1
+ """BM25 module."""
@@ -0,0 +1,71 @@
1
+ """BM25 service."""
2
+
3
+ from pathlib import Path
4
+
5
+ import bm25s
6
+ import Stemmer
7
+ import structlog
8
+ from bm25s.tokenization import Tokenized
9
+
10
+
11
+ class BM25Service:
12
+ """Service for BM25."""
13
+
14
+ def __init__(self, data_dir: Path) -> None:
15
+ """Initialize the BM25 service."""
16
+ self.log = structlog.get_logger(__name__)
17
+ self.index_path = data_dir / "bm25s_index"
18
+ try:
19
+ self.log.debug("Loading BM25 index")
20
+ self.retriever = bm25s.BM25.load(self.index_path, mmap=True)
21
+ except FileNotFoundError:
22
+ self.log.debug("BM25 index not found, creating new index")
23
+ self.retriever = bm25s.BM25()
24
+
25
+ self.stemmer = Stemmer.Stemmer("english")
26
+
27
+ def _tokenize(self, corpus: list[str]) -> list[list[str]] | Tokenized:
28
+ return bm25s.tokenize(
29
+ corpus,
30
+ stopwords="en",
31
+ stemmer=self.stemmer,
32
+ return_ids=False,
33
+ show_progress=True,
34
+ )
35
+
36
+ def index(self, corpus: list[str]) -> None:
37
+ """Index a new corpus."""
38
+ self.log.debug("Indexing corpus")
39
+ vocab = self._tokenize(corpus)
40
+ self.retriever = bm25s.BM25()
41
+ self.retriever.index(vocab)
42
+ self.retriever.save(self.index_path)
43
+
44
+ def retrieve(
45
+ self, doc_ids: list[int], query: str, top_k: int = 2
46
+ ) -> list[tuple[int, float]]:
47
+ """Retrieve from the index."""
48
+ if top_k == 0:
49
+ self.log.warning("Top k is 0, returning empty list")
50
+ return []
51
+ if len(doc_ids) == 0:
52
+ self.log.warning("No documents to retrieve from, returning empty list")
53
+ return []
54
+
55
+ top_k = min(top_k, len(doc_ids))
56
+ self.log.debug(
57
+ "Retrieving from index", query=query, top_k=top_k, num_docs=len(doc_ids)
58
+ )
59
+
60
+ query_tokens = self._tokenize([query])
61
+
62
+ self.log.debug("Query tokens", query_tokens=query_tokens)
63
+
64
+ results, scores = self.retriever.retrieve(
65
+ query_tokens=query_tokens, corpus=doc_ids, k=top_k
66
+ )
67
+ self.log.debug("Raw results", results=results, scores=scores)
68
+ return [
69
+ (int(result), float(score))
70
+ for result, score in zip(results[0], scores[0], strict=False)
71
+ ]
@@ -0,0 +1,272 @@
1
+ """Command line interface for kodit."""
2
+
3
+ import os
4
+ import signal
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import click
9
+ import structlog
10
+ import uvicorn
11
+ from pytable_formatter import Table
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from kodit.config import (
15
+ DEFAULT_BASE_DIR,
16
+ DEFAULT_DB_URL,
17
+ DEFAULT_DISABLE_TELEMETRY,
18
+ DEFAULT_LOG_FORMAT,
19
+ DEFAULT_LOG_LEVEL,
20
+ AppContext,
21
+ with_app_context,
22
+ with_session,
23
+ )
24
+ from kodit.indexing.repository import IndexRepository
25
+ from kodit.indexing.service import IndexService
26
+ from kodit.logging import configure_logging, configure_telemetry, log_event
27
+ from kodit.retreival.repository import RetrievalRepository
28
+ from kodit.retreival.service import RetrievalRequest, RetrievalService
29
+ from kodit.sources.repository import SourceRepository
30
+ from kodit.sources.service import SourceService
31
+
32
+
33
+ @click.group(context_settings={"max_content_width": 100})
34
+ @click.option("--log-level", help=f"Log level [default: {DEFAULT_LOG_LEVEL}]")
35
+ @click.option("--log-format", help=f"Log format [default: {DEFAULT_LOG_FORMAT}]")
36
+ @click.option(
37
+ "--disable-telemetry",
38
+ is_flag=True,
39
+ help=f"Disable telemetry [default: {DEFAULT_DISABLE_TELEMETRY}]",
40
+ )
41
+ @click.option("--db-url", help=f"Database URL [default: {DEFAULT_DB_URL}]")
42
+ @click.option("--data-dir", help=f"Data directory [default: {DEFAULT_BASE_DIR}]")
43
+ @click.option(
44
+ "--env-file",
45
+ help="Path to a .env file [default: .env]",
46
+ type=click.Path(
47
+ exists=True,
48
+ dir_okay=False,
49
+ resolve_path=True,
50
+ path_type=Path,
51
+ ),
52
+ )
53
+ @click.pass_context
54
+ def cli( # noqa: PLR0913
55
+ ctx: click.Context,
56
+ log_level: str | None,
57
+ log_format: str | None,
58
+ disable_telemetry: bool | None,
59
+ db_url: str | None,
60
+ data_dir: str | None,
61
+ env_file: Path | None,
62
+ ) -> None:
63
+ """kodit CLI - Code indexing for better AI code generation.""" # noqa: D403
64
+ config = AppContext()
65
+ # First check if env-file is set and reload config if it is
66
+ if env_file:
67
+ config = AppContext(_env_file=env_file) # type: ignore[reportCallIssue]
68
+
69
+ # Now override with CLI arguments, if set
70
+ if data_dir:
71
+ config.data_dir = Path(data_dir)
72
+ if db_url:
73
+ config.db_url = db_url
74
+ if log_level:
75
+ config.log_level = log_level
76
+ if log_format:
77
+ config.log_format = log_format
78
+ if disable_telemetry:
79
+ config.disable_telemetry = disable_telemetry
80
+ configure_logging(config)
81
+ configure_telemetry(config)
82
+
83
+ # Set the app context in the click context for downstream cli
84
+ ctx.obj = config
85
+
86
+
87
+ @cli.group()
88
+ def sources() -> None:
89
+ """Manage code sources."""
90
+
91
+
92
+ @sources.command(name="list")
93
+ @with_app_context
94
+ @with_session
95
+ async def list_sources(session: AsyncSession, app_context: AppContext) -> None:
96
+ """List all code sources."""
97
+ repository = SourceRepository(session)
98
+ service = SourceService(app_context.get_clone_dir(), repository)
99
+ sources = await service.list_sources()
100
+
101
+ # Define headers and data
102
+ headers = ["ID", "Created At", "URI"]
103
+ data = [[source.id, source.created_at, source.uri] for source in sources]
104
+
105
+ # Create and display the table
106
+ table = Table(headers=headers, data=data)
107
+ click.echo(table)
108
+
109
+
110
+ @sources.command(name="create")
111
+ @click.argument("uri")
112
+ @with_app_context
113
+ @with_session
114
+ async def create_source(
115
+ session: AsyncSession, app_context: AppContext, uri: str
116
+ ) -> None:
117
+ """Add a new code source."""
118
+ repository = SourceRepository(session)
119
+ service = SourceService(app_context.get_clone_dir(), repository)
120
+ source = await service.create(uri)
121
+ click.echo(f"Source created: {source.id}")
122
+
123
+
124
+ @cli.group()
125
+ def indexes() -> None:
126
+ """Manage indexes."""
127
+
128
+
129
+ @indexes.command(name="create")
130
+ @click.argument("source_id")
131
+ @with_app_context
132
+ @with_session
133
+ async def create_index(
134
+ session: AsyncSession, app_context: AppContext, source_id: int
135
+ ) -> None:
136
+ """Create an index for a source."""
137
+ source_repository = SourceRepository(session)
138
+ source_service = SourceService(app_context.get_clone_dir(), source_repository)
139
+ repository = IndexRepository(session)
140
+ service = IndexService(repository, source_service, app_context.get_data_dir())
141
+ index = await service.create(source_id)
142
+ click.echo(f"Index created: {index.id}")
143
+
144
+
145
+ @indexes.command(name="list")
146
+ @with_app_context
147
+ @with_session
148
+ async def list_indexes(session: AsyncSession, app_context: AppContext) -> None:
149
+ """List all indexes."""
150
+ source_repository = SourceRepository(session)
151
+ source_service = SourceService(app_context.get_clone_dir(), source_repository)
152
+ repository = IndexRepository(session)
153
+ service = IndexService(repository, source_service, app_context.get_data_dir())
154
+ indexes = await service.list_indexes()
155
+
156
+ # Define headers and data
157
+ headers = [
158
+ "ID",
159
+ "Created At",
160
+ "Updated At",
161
+ "Num Snippets",
162
+ ]
163
+ data = [
164
+ [
165
+ index.id,
166
+ index.created_at,
167
+ index.updated_at,
168
+ index.num_snippets,
169
+ ]
170
+ for index in indexes
171
+ ]
172
+
173
+ # Create and display the table
174
+ table = Table(headers=headers, data=data)
175
+ click.echo(table)
176
+
177
+
178
+ @indexes.command(name="run")
179
+ @click.argument("index_id")
180
+ @with_app_context
181
+ @with_session
182
+ async def run_index(
183
+ session: AsyncSession, app_context: AppContext, index_id: int
184
+ ) -> None:
185
+ """Run an index."""
186
+ source_repository = SourceRepository(session)
187
+ source_service = SourceService(app_context.get_clone_dir(), source_repository)
188
+ repository = IndexRepository(session)
189
+ service = IndexService(repository, source_service, app_context.get_data_dir())
190
+ await service.run(index_id)
191
+
192
+
193
+ @cli.command()
194
+ @click.argument("query")
195
+ @click.option("--top-k", default=10, help="Number of snippets to retrieve")
196
+ @with_app_context
197
+ @with_session
198
+ async def retrieve(
199
+ session: AsyncSession, app_context: AppContext, query: str, top_k: int
200
+ ) -> None:
201
+ """Retrieve snippets from the database."""
202
+ repository = RetrievalRepository(session)
203
+ service = RetrievalService(repository, app_context.get_data_dir())
204
+ # Temporary request while we don't have all search capabilities
205
+ snippets = await service.retrieve(
206
+ RetrievalRequest(keywords=query.split(","), top_k=top_k)
207
+ )
208
+
209
+ if len(snippets) == 0:
210
+ click.echo("No snippets found")
211
+ return
212
+
213
+ for snippet in snippets:
214
+ click.echo("-" * 80)
215
+ click.echo(f"{snippet.uri}")
216
+ click.echo(snippet.content)
217
+ click.echo("-" * 80)
218
+ click.echo()
219
+
220
+
221
+ @cli.command()
222
+ @click.option("--host", default="127.0.0.1", help="Host to bind the server to")
223
+ @click.option("--port", default=8080, help="Port to bind the server to")
224
+ @with_app_context
225
+ def serve(
226
+ app_context: AppContext,
227
+ host: str,
228
+ port: int,
229
+ ) -> None:
230
+ """Start the kodit server, which hosts the MCP server and the kodit API."""
231
+ log = structlog.get_logger(__name__)
232
+ log.info("Starting kodit server", host=host, port=port)
233
+ log_event("kodit_server_started")
234
+
235
+ # Dump AppContext to a dictionary of strings, and set the env vars
236
+ app_context_dict = {k: str(v) for k, v in app_context.model_dump().items()}
237
+ os.environ.update(app_context_dict)
238
+
239
+ # Configure uvicorn with graceful shutdown
240
+ config = uvicorn.Config(
241
+ "kodit.app:app",
242
+ host=host,
243
+ port=port,
244
+ reload=False,
245
+ log_config=None, # Setting to None forces uvicorn to use our structlog setup
246
+ access_log=False, # Using own middleware for access logging
247
+ timeout_graceful_shutdown=0, # The mcp server does not shutdown cleanly, force
248
+ )
249
+ server = uvicorn.Server(config)
250
+
251
+ def handle_sigint(signum: int, frame: Any) -> None:
252
+ """Handle SIGINT (Ctrl+C)."""
253
+ log.info("Received shutdown signal, force killing MCP connections")
254
+ server.handle_exit(signum, frame)
255
+
256
+ signal.signal(signal.SIGINT, handle_sigint)
257
+ server.run()
258
+
259
+
260
+ @cli.command()
261
+ def version() -> None:
262
+ """Show the version of kodit."""
263
+ try:
264
+ from kodit import _version
265
+ except ImportError:
266
+ print("unknown, try running `uv build`, which is what happens in ci") # noqa: T201
267
+ else:
268
+ print(_version.version) # noqa: T201
269
+
270
+
271
+ if __name__ == "__main__":
272
+ cli()
@@ -0,0 +1,97 @@
1
+ """Global configuration for the kodit project."""
2
+
3
+ import asyncio
4
+ from collections.abc import Callable, Coroutine
5
+ from functools import wraps
6
+ from pathlib import Path
7
+ from typing import Any, TypeVar
8
+
9
+ import click
10
+ from pydantic import Field
11
+ from pydantic_settings import BaseSettings, SettingsConfigDict
12
+
13
+ from kodit.database import Database
14
+
15
+ DEFAULT_BASE_DIR = Path.home() / ".kodit"
16
+ DEFAULT_DB_URL = f"sqlite+aiosqlite:///{DEFAULT_BASE_DIR}/kodit.db"
17
+ DEFAULT_LOG_LEVEL = "INFO"
18
+ DEFAULT_LOG_FORMAT = "pretty"
19
+ DEFAULT_DISABLE_TELEMETRY = False
20
+ T = TypeVar("T")
21
+
22
+
23
+ class AppContext(BaseSettings):
24
+ """Global context for the kodit project. Provides a shared state for the app."""
25
+
26
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
27
+
28
+ data_dir: Path = Field(default=DEFAULT_BASE_DIR)
29
+ db_url: str = Field(default=DEFAULT_DB_URL)
30
+ log_level: str = Field(default=DEFAULT_LOG_LEVEL)
31
+ log_format: str = Field(default=DEFAULT_LOG_FORMAT)
32
+ disable_telemetry: bool = Field(default=DEFAULT_DISABLE_TELEMETRY)
33
+ _db: Database | None = None
34
+
35
+ def model_post_init(self, _: Any) -> None:
36
+ """Post-initialization hook."""
37
+ # Call this to ensure the data dir exists for the default db location
38
+ self.get_data_dir()
39
+
40
+ def get_data_dir(self) -> Path:
41
+ """Get the data directory."""
42
+ self.data_dir.mkdir(parents=True, exist_ok=True)
43
+ return self.data_dir
44
+
45
+ def get_clone_dir(self) -> Path:
46
+ """Get the clone directory."""
47
+ clone_dir = self.get_data_dir() / "clones"
48
+ clone_dir.mkdir(parents=True, exist_ok=True)
49
+ return clone_dir
50
+
51
+ async def get_db(self, *, run_migrations: bool = True) -> Database:
52
+ """Get the database."""
53
+ if self._db is None:
54
+ self._db = Database(self.db_url)
55
+ if run_migrations:
56
+ await self._db.run_migrations(self.db_url)
57
+ return self._db
58
+
59
+
60
+ with_app_context = click.make_pass_decorator(AppContext)
61
+
62
+ T = TypeVar("T")
63
+
64
+
65
+ def wrap_async(f: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., T]:
66
+ """Decorate async Click commands.
67
+
68
+ This decorator wraps an async function to run it with asyncio.run().
69
+ It should be used after the Click command decorator.
70
+
71
+ Example:
72
+ @cli.command()
73
+ @wrap_async
74
+ async def my_command():
75
+ ...
76
+
77
+ """
78
+
79
+ @wraps(f)
80
+ def wrapper(*args: Any, **kwargs: Any) -> T:
81
+ return asyncio.run(f(*args, **kwargs))
82
+
83
+ return wrapper
84
+
85
+
86
+ def with_session(f: Callable[..., Coroutine[Any, Any, T]]) -> Callable[..., T]:
87
+ """Provide a database session to CLI commands."""
88
+
89
+ @wraps(f)
90
+ @with_app_context
91
+ @wrap_async
92
+ async def wrapper(app_context: AppContext, *args: Any, **kwargs: Any) -> T:
93
+ db = await app_context.get_db()
94
+ async with db.session_factory() as session:
95
+ return await f(session, *args, **kwargs)
96
+
97
+ return wrapper