cocoindex-code 0.2.6__tar.gz → 0.2.8__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.
Files changed (20) hide show
  1. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/PKG-INFO +37 -1
  2. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/README.md +36 -0
  3. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/_version.py +2 -2
  4. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/cli.py +67 -16
  5. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/client.py +58 -10
  6. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/config.py +4 -2
  7. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/daemon.py +9 -1
  8. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/project.py +9 -5
  9. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/protocol.py +6 -0
  10. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/settings.py +95 -13
  11. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/.gitignore +0 -0
  12. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/LICENSE +0 -0
  13. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/pyproject.toml +0 -0
  14. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/__init__.py +0 -0
  15. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/__main__.py +0 -0
  16. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/indexer.py +0 -0
  17. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/query.py +0 -0
  18. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/schema.py +0 -0
  19. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/server.py +0 -0
  20. {cocoindex_code-0.2.6 → cocoindex_code-0.2.8}/src/cocoindex_code/shared.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cocoindex-code
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: MCP server for indexing and querying codebases using CocoIndex
5
5
  Project-URL: Homepage, https://github.com/cocoindex-io/cocoindex-code
6
6
  Project-URL: Repository, https://github.com/cocoindex-io/cocoindex-code
@@ -208,6 +208,7 @@ The background daemon starts automatically on first use.
208
208
  | `ccc search <query>` | Semantic search across the codebase |
209
209
  | `ccc status` | Show index stats (chunk count, file count, language breakdown) |
210
210
  | `ccc mcp` | Run as MCP server in stdio mode |
211
+ | `ccc doctor` | Run diagnostics — checks settings, daemon, model, file matching, and index health |
211
212
  | `ccc reset` | Delete index databases. `--all` also removes settings. `-f` skips confirmation. |
212
213
  | `ccc daemon status` | Show daemon version, uptime, and loaded projects |
213
214
  | `ccc daemon restart` | Restart the background daemon |
@@ -456,8 +457,30 @@ embedding:
456
457
  | xml | | `.xml` |
457
458
  | yaml | | `.yaml`, `.yml` |
458
459
 
460
+ ### Custom Database Location
461
+
462
+ By default, index databases (`cocoindex.db` and `target_sqlite.db`) live alongside settings in `<project>/.cocoindex_code/`. When running in Docker, you may want the databases on the container's native filesystem for performance (LMDB doesn't work well on mounted volumes) while keeping the source code and settings on a mounted volume.
463
+
464
+ Set `COCOINDEX_CODE_DB_PATH_MAPPING` to remap database locations by path prefix:
465
+
466
+ ```bash
467
+ COCOINDEX_CODE_DB_PATH_MAPPING=/workspace=/db-files
468
+ ```
469
+
470
+ With this mapping, a project at `/workspace/myrepo` stores its databases in `/db-files/myrepo/` instead of `/workspace/myrepo/.cocoindex_code/`. Settings files remain in the original location.
471
+
472
+ Multiple mappings are comma-separated and resolved in order (first match wins):
473
+
474
+ ```bash
475
+ COCOINDEX_CODE_DB_PATH_MAPPING=/workspace=/db-files,/workspace2=/db-files2
476
+ ```
477
+
478
+ Both source and target must be absolute paths. If no mapping matches, the default location is used.
479
+
459
480
  ## Troubleshooting
460
481
 
482
+ Run `ccc doctor` to diagnose common issues. It checks your settings, daemon health, embedding model, file matching, and index status — all in one command.
483
+
461
484
  ### `sqlite3.Connection object has no attribute enable_load_extension`
462
485
 
463
486
  Some Python installations (e.g. the one pre-installed on macOS) ship with a SQLite library that doesn't enable extensions.
@@ -498,6 +521,19 @@ If you previously configured `cocoindex-code` via environment variables, the `co
498
521
 
499
522
  If you need help with remote setup, please email our maintainer linghua@cocoindex.io, happy to help!
500
523
 
524
+ ## Contributing
525
+
526
+ We welcome contributions! Before you start, please install the [pre-commit](https://pre-commit.com/) hooks so that linting, formatting, type checking, and tests run automatically before each commit:
527
+
528
+ ```bash
529
+ pip install pre-commit
530
+ pre-commit install
531
+ ```
532
+
533
+ This catches common issues — trailing whitespace, lint errors (Ruff), type errors (mypy), and test failures — before they reach CI.
534
+
535
+ For more details, see our [contributing guide](https://cocoindex.io/docs/contributing/guide).
536
+
501
537
  ## License
502
538
 
503
539
  Apache-2.0
@@ -169,6 +169,7 @@ The background daemon starts automatically on first use.
169
169
  | `ccc search <query>` | Semantic search across the codebase |
170
170
  | `ccc status` | Show index stats (chunk count, file count, language breakdown) |
171
171
  | `ccc mcp` | Run as MCP server in stdio mode |
172
+ | `ccc doctor` | Run diagnostics — checks settings, daemon, model, file matching, and index health |
172
173
  | `ccc reset` | Delete index databases. `--all` also removes settings. `-f` skips confirmation. |
173
174
  | `ccc daemon status` | Show daemon version, uptime, and loaded projects |
174
175
  | `ccc daemon restart` | Restart the background daemon |
@@ -417,8 +418,30 @@ embedding:
417
418
  | xml | | `.xml` |
418
419
  | yaml | | `.yaml`, `.yml` |
419
420
 
421
+ ### Custom Database Location
422
+
423
+ By default, index databases (`cocoindex.db` and `target_sqlite.db`) live alongside settings in `<project>/.cocoindex_code/`. When running in Docker, you may want the databases on the container's native filesystem for performance (LMDB doesn't work well on mounted volumes) while keeping the source code and settings on a mounted volume.
424
+
425
+ Set `COCOINDEX_CODE_DB_PATH_MAPPING` to remap database locations by path prefix:
426
+
427
+ ```bash
428
+ COCOINDEX_CODE_DB_PATH_MAPPING=/workspace=/db-files
429
+ ```
430
+
431
+ With this mapping, a project at `/workspace/myrepo` stores its databases in `/db-files/myrepo/` instead of `/workspace/myrepo/.cocoindex_code/`. Settings files remain in the original location.
432
+
433
+ Multiple mappings are comma-separated and resolved in order (first match wins):
434
+
435
+ ```bash
436
+ COCOINDEX_CODE_DB_PATH_MAPPING=/workspace=/db-files,/workspace2=/db-files2
437
+ ```
438
+
439
+ Both source and target must be absolute paths. If no mapping matches, the default location is used.
440
+
420
441
  ## Troubleshooting
421
442
 
443
+ Run `ccc doctor` to diagnose common issues. It checks your settings, daemon health, embedding model, file matching, and index status — all in one command.
444
+
422
445
  ### `sqlite3.Connection object has no attribute enable_load_extension`
423
446
 
424
447
  Some Python installations (e.g. the one pre-installed on macOS) ship with a SQLite library that doesn't enable extensions.
@@ -459,6 +482,19 @@ If you previously configured `cocoindex-code` via environment variables, the `co
459
482
 
460
483
  If you need help with remote setup, please email our maintainer linghua@cocoindex.io, happy to help!
461
484
 
485
+ ## Contributing
486
+
487
+ We welcome contributions! Before you start, please install the [pre-commit](https://pre-commit.com/) hooks so that linting, formatting, type checking, and tests run automatically before each commit:
488
+
489
+ ```bash
490
+ pip install pre-commit
491
+ pre-commit install
492
+ ```
493
+
494
+ This catches common issues — trailing whitespace, lint errors (Ruff), type errors (mypy), and test failures — before they reach CI.
495
+
496
+ For more details, see our [contributing guide](https://cocoindex.io/docs/contributing/guide).
497
+
462
498
  ## License
463
499
 
464
500
  Apache-2.0
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.6'
32
- __version_tuple__ = version_tuple = (0, 2, 6)
31
+ __version__ = version = '0.2.8'
32
+ __version_tuple__ = version_tuple = (0, 2, 8)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -2,16 +2,21 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import functools
6
+ from collections.abc import Callable
5
7
  from pathlib import Path
8
+ from typing import TypeVar
6
9
 
7
10
  import typer as _typer
8
11
 
12
+ from .client import DaemonStartError
9
13
  from .protocol import DoctorCheckResult, IndexingProgress, ProjectStatusResponse, SearchResponse
10
14
  from .settings import (
11
15
  default_project_settings,
12
16
  default_user_settings,
13
17
  find_parent_with_marker,
14
18
  find_project_root,
19
+ resolve_db_dir,
15
20
  save_project_settings,
16
21
  save_user_settings,
17
22
  user_settings_path,
@@ -35,8 +40,17 @@ app.add_typer(daemon_app, name="daemon")
35
40
  def require_project_root() -> Path:
36
41
  """Find the project root by walking up from CWD.
37
42
 
38
- Exits with code 1 if not found.
43
+ Checks global settings first (more fundamental), then project settings.
44
+ Exits with code 1 if either check fails.
39
45
  """
46
+ gs_path = user_settings_path()
47
+ if not gs_path.is_file():
48
+ _typer.echo(
49
+ f"Error: Global settings not found: {gs_path}\n"
50
+ "Run `ccc init` to create it with default settings.",
51
+ err=True,
52
+ )
53
+ raise _typer.Exit(code=1)
40
54
  root = find_project_root(Path.cwd())
41
55
  if root is None:
42
56
  _typer.echo(
@@ -48,6 +62,26 @@ def require_project_root() -> Path:
48
62
  return root
49
63
 
50
64
 
65
+ _F = TypeVar("_F", bound=Callable[..., object])
66
+
67
+
68
+ def _catch_daemon_start_error(func: _F) -> _F:
69
+ """Decorator that catches ``DaemonStartError`` and exits with a clean message.
70
+
71
+ Apply to any CLI command that may trigger daemon auto-start.
72
+ """
73
+
74
+ @functools.wraps(func)
75
+ def wrapper(*args: object, **kwargs: object) -> object:
76
+ try:
77
+ return func(*args, **kwargs)
78
+ except DaemonStartError as e:
79
+ _typer.echo(f"Error: {e}", err=True)
80
+ raise _typer.Exit(code=1)
81
+
82
+ return wrapper # type: ignore[return-value]
83
+
84
+
51
85
  def resolve_default_path(project_root: Path) -> str | None:
52
86
  """Compute default ``--path`` filter from CWD relative to project root."""
53
87
  cwd = Path.cwd().resolve()
@@ -138,6 +172,9 @@ def _run_index_with_progress(project_root: str) -> None:
138
172
  resp = _client.index(project_root, on_progress=_on_progress, on_waiting=_on_waiting)
139
173
  except RuntimeError as e:
140
174
  live.stop()
175
+ # Let DaemonStartError propagate to the decorator for consistent handling.
176
+ if isinstance(e, DaemonStartError):
177
+ raise
141
178
  _typer.echo(f"Indexing failed: {e}", err=True)
142
179
  raise _typer.Exit(code=1)
143
180
 
@@ -252,6 +289,12 @@ def init(
252
289
  cwd = Path.cwd().resolve()
253
290
  settings_file = _project_settings_path(cwd)
254
291
 
292
+ # Always ensure user settings exist
293
+ user_path = user_settings_path()
294
+ if not user_path.is_file():
295
+ save_user_settings(default_user_settings())
296
+ _typer.echo(f"Created user settings: {user_path}")
297
+
255
298
  # Check if already initialized
256
299
  if settings_file.is_file():
257
300
  _typer.echo("Project already initialized.")
@@ -268,12 +311,6 @@ def init(
268
311
  )
269
312
  raise _typer.Exit(code=1)
270
313
 
271
- # Create user settings if missing
272
- user_path = user_settings_path()
273
- if not user_path.is_file():
274
- save_user_settings(default_user_settings())
275
- _typer.echo(f"Created user settings: {user_path}")
276
-
277
314
  # Create project settings
278
315
  save_project_settings(cwd, default_project_settings())
279
316
  _typer.echo(f"Created project settings: {settings_file}")
@@ -286,6 +323,7 @@ def init(
286
323
 
287
324
 
288
325
  @app.command()
326
+ @_catch_daemon_start_error
289
327
  def index() -> None:
290
328
  """Create/update index for the codebase."""
291
329
  from . import client as _client
@@ -297,6 +335,7 @@ def index() -> None:
297
335
 
298
336
 
299
337
  @app.command()
338
+ @_catch_daemon_start_error
300
339
  def search(
301
340
  query: list[str] = _typer.Argument(..., help="Search query"),
302
341
  lang: list[str] = _typer.Option([], "--lang", help="Filter by language"),
@@ -333,6 +372,7 @@ def search(
333
372
 
334
373
 
335
374
  @app.command()
375
+ @_catch_daemon_start_error
336
376
  def status() -> None:
337
377
  """Show project status."""
338
378
  from . import client as _client
@@ -350,10 +390,11 @@ def reset(
350
390
  """Reset project databases and optionally remove settings."""
351
391
  project_root = require_project_root()
352
392
  cocoindex_dir = project_root / ".cocoindex_code"
393
+ db_dir = resolve_db_dir(project_root)
353
394
 
354
395
  db_files = [
355
- cocoindex_dir / "cocoindex.db",
356
- cocoindex_dir / "target_sqlite.db",
396
+ db_dir / "cocoindex.db",
397
+ db_dir / "target_sqlite.db",
357
398
  ]
358
399
  settings_file = cocoindex_dir / "settings.yml"
359
400
 
@@ -397,6 +438,12 @@ def reset(
397
438
  f.unlink(missing_ok=True)
398
439
 
399
440
  if all_:
441
+ # Remove db_dir if empty and different from cocoindex_dir
442
+ if db_dir != cocoindex_dir:
443
+ try:
444
+ db_dir.rmdir()
445
+ except OSError:
446
+ pass # Not empty or doesn't exist
400
447
  # Remove .cocoindex_code/ if empty
401
448
  try:
402
449
  cocoindex_dir.rmdir()
@@ -446,6 +493,7 @@ def _print_doctor_result(result: DoctorCheckResult) -> None:
446
493
 
447
494
 
448
495
  @app.command()
496
+ @_catch_daemon_start_error
449
497
  def doctor() -> None:
450
498
  """Check system health and report issues."""
451
499
  from . import client as _client
@@ -499,6 +547,10 @@ def doctor() -> None:
499
547
  other_keys = [k for k in env_resp.env_names if k not in settings_keys]
500
548
  if other_keys:
501
549
  _typer.echo(f" Other env vars in daemon: {', '.join(sorted(other_keys))}")
550
+ if env_resp.db_path_mappings:
551
+ _typer.echo(" DB path mappings:")
552
+ for m in env_resp.db_path_mappings:
553
+ _typer.echo(f" {m.source} \u2192 {m.target}")
502
554
  except Exception as e:
503
555
  _print_error(f"Failed to get daemon env: {e}")
504
556
 
@@ -553,6 +605,7 @@ def doctor() -> None:
553
605
 
554
606
 
555
607
  @app.command()
608
+ @_catch_daemon_start_error
556
609
  def mcp() -> None:
557
610
  """Run as MCP server (stdio mode)."""
558
611
  import asyncio
@@ -586,6 +639,7 @@ async def _bg_index(project_root: str) -> None:
586
639
 
587
640
 
588
641
  @daemon_app.command("status")
642
+ @_catch_daemon_start_error
589
643
  def daemon_status() -> None:
590
644
  """Show daemon status."""
591
645
  from . import client as _client
@@ -603,6 +657,7 @@ def daemon_status() -> None:
603
657
 
604
658
 
605
659
  @daemon_app.command("restart")
660
+ @_catch_daemon_start_error
606
661
  def daemon_restart() -> None:
607
662
  """Restart the daemon."""
608
663
  from .client import _wait_for_daemon, start_daemon, stop_daemon
@@ -611,13 +666,9 @@ def daemon_restart() -> None:
611
666
  stop_daemon()
612
667
 
613
668
  _typer.echo("Starting daemon...")
614
- start_daemon()
615
- try:
616
- _wait_for_daemon()
617
- _typer.echo("Daemon restarted.")
618
- except TimeoutError:
619
- _typer.echo("Error: Daemon did not start in time.", err=True)
620
- raise _typer.Exit(code=1)
669
+ proc = start_daemon()
670
+ _wait_for_daemon(proc=proc)
671
+ _typer.echo("Daemon restarted.")
621
672
 
622
673
 
623
674
  @daemon_app.command("stop")
@@ -18,7 +18,7 @@ from multiprocessing.connection import Client, Connection
18
18
  from pathlib import Path
19
19
 
20
20
  from ._version import __version__
21
- from .daemon import _connection_family, daemon_pid_path, daemon_socket_path
21
+ from .daemon import _connection_family, daemon_log_path, daemon_pid_path, daemon_socket_path
22
22
  from .protocol import (
23
23
  DaemonEnvRequest,
24
24
  DaemonEnvResponse,
@@ -84,8 +84,8 @@ def _connect_and_handshake() -> Connection:
84
84
  except (ConnectionRefusedError, OSError):
85
85
  pass
86
86
 
87
- start_daemon()
88
- _wait_for_daemon()
87
+ proc = start_daemon()
88
+ _wait_for_daemon(proc=proc)
89
89
 
90
90
  # Verify the fresh daemon is reachable
91
91
  for _attempt in range(10):
@@ -145,6 +145,27 @@ class DaemonVersionError(RuntimeError):
145
145
  )
146
146
 
147
147
 
148
+ class DaemonStartError(RuntimeError):
149
+ """Raised when the daemon process fails to start.
150
+
151
+ Carries the daemon log content so callers can display it to the user.
152
+ """
153
+
154
+ def __init__(self, message: str, log: str | None = None) -> None:
155
+ self.log = log
156
+ super().__init__(message)
157
+
158
+
159
+ def _read_daemon_log() -> str | None:
160
+ """Read the daemon log file, returning its content or None."""
161
+ log_path = daemon_log_path()
162
+ try:
163
+ content = log_path.read_text().strip()
164
+ return content if content else None
165
+ except (FileNotFoundError, OSError):
166
+ return None
167
+
168
+
148
169
  def _send(req: Request) -> Response:
149
170
  """Open connection, handshake, send one request, read one response, close."""
150
171
  conn = _connect_and_handshake()
@@ -316,8 +337,12 @@ def is_daemon_running() -> bool:
316
337
  return os.path.exists(daemon_socket_path())
317
338
 
318
339
 
319
- def start_daemon() -> None:
320
- """Start the daemon as a background process."""
340
+ def start_daemon() -> subprocess.Popen[bytes]:
341
+ """Start the daemon as a background process.
342
+
343
+ Returns the ``Popen`` object so callers can detect early process death
344
+ (via ``proc.poll()``) instead of waiting for a full timeout.
345
+ """
321
346
  from .daemon import daemon_dir
322
347
 
323
348
  daemon_dir().mkdir(parents=True, exist_ok=True)
@@ -332,7 +357,7 @@ def start_daemon() -> None:
332
357
  log_fd = open(log_path, "w")
333
358
  if sys.platform == "win32":
334
359
  _create_no_window = 0x08000000
335
- subprocess.Popen(
360
+ proc = subprocess.Popen(
336
361
  cmd,
337
362
  stdout=log_fd,
338
363
  stderr=log_fd,
@@ -340,7 +365,7 @@ def start_daemon() -> None:
340
365
  creationflags=_create_no_window,
341
366
  )
342
367
  else:
343
- subprocess.Popen(
368
+ proc = subprocess.Popen(
344
369
  cmd,
345
370
  start_new_session=True,
346
371
  stdout=log_fd,
@@ -348,6 +373,7 @@ def start_daemon() -> None:
348
373
  stdin=subprocess.DEVNULL,
349
374
  )
350
375
  log_fd.close()
376
+ return proc
351
377
 
352
378
 
353
379
  def _find_ccc_executable() -> str | None:
@@ -469,11 +495,27 @@ def _cleanup_stale_files(pid_path: Path, pid: int | None) -> None:
469
495
  pass
470
496
 
471
497
 
472
- def _wait_for_daemon(timeout: float = 30.0) -> None:
473
- """Wait for the daemon socket/pipe to become available."""
498
+ def _wait_for_daemon(
499
+ timeout: float = 30.0,
500
+ proc: subprocess.Popen[bytes] | None = None,
501
+ ) -> None:
502
+ """Wait for the daemon socket/pipe to become available.
503
+
504
+ If *proc* is given, polls the process each iteration. When the process
505
+ exits before the socket appears, raises ``DaemonStartError`` immediately
506
+ with the daemon log content — no need to wait for the full timeout.
507
+ """
474
508
  deadline = time.monotonic() + timeout
475
509
  sock_path = daemon_socket_path()
476
510
  while time.monotonic() < deadline:
511
+ # Check if the daemon process died before the socket appeared.
512
+ if proc is not None and proc.poll() is not None:
513
+ log = _read_daemon_log()
514
+ msg = "Daemon process exited before it became ready."
515
+ if log:
516
+ msg += f"\n\nDaemon log:\n{log}"
517
+ raise DaemonStartError(msg, log=log)
518
+
477
519
  if sys.platform == "win32":
478
520
  try:
479
521
  conn = Client(sock_path, family=_connection_family())
@@ -485,7 +527,13 @@ def _wait_for_daemon(timeout: float = 30.0) -> None:
485
527
  if os.path.exists(sock_path):
486
528
  return
487
529
  time.sleep(0.2)
488
- raise TimeoutError("Daemon did not start in time")
530
+
531
+ # Timeout — also include log for diagnostics.
532
+ log = _read_daemon_log()
533
+ msg = "Daemon did not start in time."
534
+ if log:
535
+ msg += f"\n\nDaemon log:\n{log}"
536
+ raise DaemonStartError(msg, log=log)
489
537
 
490
538
 
491
539
  def _needs_restart(resp: HandshakeResponse) -> bool:
@@ -7,6 +7,8 @@ import os
7
7
  from dataclasses import dataclass
8
8
  from pathlib import Path
9
9
 
10
+ from .settings import resolve_db_dir
11
+
10
12
  _DEFAULT_MODEL = "sbert/sentence-transformers/all-MiniLM-L6-v2"
11
13
 
12
14
 
@@ -96,8 +98,8 @@ class Config:
96
98
  _DEFAULT_MODEL,
97
99
  )
98
100
 
99
- # Index directory is always under the root
100
- index_dir = root / ".cocoindex_code"
101
+ # Index directory: apply DB path mapping if configured
102
+ index_dir = resolve_db_dir(root)
101
103
 
102
104
  # Device: auto-detect CUDA or use env override
103
105
  device = os.environ.get("COCOINDEX_CODE_DEVICE")
@@ -48,6 +48,7 @@ from .protocol import (
48
48
  from .settings import (
49
49
  global_settings_mtime_us,
50
50
  load_user_settings,
51
+ resolve_db_dir,
51
52
  user_settings_dir,
52
53
  )
53
54
  from .shared import Embedder, create_embedder
@@ -345,7 +346,7 @@ async def _check_index_status(project_root_str: str) -> DoctorCheckResult:
345
346
  from cocoindex.connectors import sqlite as coco_sqlite
346
347
 
347
348
  project_root = Path(project_root_str)
348
- db_path = project_root / ".cocoindex_code" / "target_sqlite.db"
349
+ db_path = resolve_db_dir(project_root) / "target_sqlite.db"
349
350
  details = [f"Index: {db_path}"]
350
351
 
351
352
  if not db_path.exists():
@@ -441,9 +442,16 @@ async def _dispatch(
441
442
  return StopResponse(ok=True)
442
443
 
443
444
  if isinstance(req, DaemonEnvRequest):
445
+ from .protocol import DbPathMappingEntry
446
+ from .settings import get_db_path_mappings
447
+
444
448
  return DaemonEnvResponse(
445
449
  env_names=sorted(os.environ.keys()),
446
450
  settings_env_names=settings_env_names,
451
+ db_path_mappings=[
452
+ DbPathMappingEntry(source=str(m.source), target=str(m.target))
453
+ for m in get_db_path_mappings()
454
+ ],
447
455
  )
448
456
 
449
457
  if isinstance(req, DoctorRequest):
@@ -21,6 +21,7 @@ from .protocol import (
21
21
  SearchResult,
22
22
  )
23
23
  from .query import query_codebase
24
+ from .settings import resolve_db_dir
24
25
  from .shared import (
25
26
  CODEBASE_DIR,
26
27
  EMBEDDER,
@@ -170,7 +171,7 @@ class Project:
170
171
  offset: int = 0,
171
172
  ) -> list[SearchResult]:
172
173
  """Search within this project."""
173
- target_db = self._project_root / ".cocoindex_code" / "target_sqlite.db"
174
+ target_db = resolve_db_dir(self._project_root) / "target_sqlite.db"
174
175
  results = await query_codebase(
175
176
  query=query,
176
177
  target_sqlite_db_path=target_db,
@@ -254,11 +255,14 @@ class Project:
254
255
  indexer loads them fresh from disk on every run so that user edits
255
256
  take effect without restarting the daemon.
256
257
  """
257
- index_dir = project_root / ".cocoindex_code"
258
- index_dir.mkdir(parents=True, exist_ok=True)
258
+ settings_dir = project_root / ".cocoindex_code"
259
+ settings_dir.mkdir(parents=True, exist_ok=True)
259
260
 
260
- cocoindex_db_path = index_dir / "cocoindex.db"
261
- target_sqlite_db_path = index_dir / "target_sqlite.db"
261
+ db_dir = resolve_db_dir(project_root)
262
+ db_dir.mkdir(parents=True, exist_ok=True)
263
+
264
+ cocoindex_db_path = db_dir / "cocoindex.db"
265
+ target_sqlite_db_path = db_dir / "target_sqlite.db"
262
266
 
263
267
  settings = coco.Settings.from_env(cocoindex_db_path)
264
268
 
@@ -158,9 +158,15 @@ class DoctorResponse(_msgspec.Struct, tag="doctor"):
158
158
  final: bool = False
159
159
 
160
160
 
161
+ class DbPathMappingEntry(_msgspec.Struct):
162
+ source: str
163
+ target: str
164
+
165
+
161
166
  class DaemonEnvResponse(_msgspec.Struct, tag="daemon_env"):
162
167
  env_names: list[str]
163
168
  settings_env_names: list[str]
169
+ db_path_mappings: list[DbPathMappingEntry] = []
164
170
 
165
171
 
166
172
  class ErrorResponse(_msgspec.Struct, tag="error"):
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  from dataclasses import dataclass, field
6
7
  from pathlib import Path
7
8
  from typing import Any
@@ -115,14 +116,89 @@ _SETTINGS_DIR_NAME = ".cocoindex_code"
115
116
  _SETTINGS_FILE_NAME = "settings.yml" # project-level
116
117
  _USER_SETTINGS_FILE_NAME = "global_settings.yml" # user-level
117
118
 
119
+ _ENV_DB_PATH_MAPPING = "COCOINDEX_CODE_DB_PATH_MAPPING"
120
+
121
+
122
+ @dataclass
123
+ class DbPathMapping:
124
+ source: Path
125
+ target: Path
126
+
127
+
128
+ _db_path_mapping: list[DbPathMapping] | None = None
129
+
130
+
131
+ def _parse_db_path_mapping() -> list[DbPathMapping]:
132
+ """Parse ``COCOINDEX_CODE_DB_PATH_MAPPING`` env var.
133
+
134
+ Format: ``/src1=/dst1,/src2=/dst2``
135
+ Both source and target must be absolute paths.
136
+ """
137
+ raw = os.environ.get(_ENV_DB_PATH_MAPPING, "")
138
+ if not raw.strip():
139
+ return []
140
+
141
+ mappings: list[DbPathMapping] = []
142
+ for entry in raw.split(","):
143
+ entry = entry.strip()
144
+ if not entry:
145
+ continue
146
+ parts = entry.split("=", 1)
147
+ if len(parts) != 2 or not parts[0] or not parts[1]:
148
+ raise ValueError(
149
+ f"{_ENV_DB_PATH_MAPPING}: invalid entry {entry!r}, expected format 'source=target'"
150
+ )
151
+ source = Path(parts[0])
152
+ target = Path(parts[1])
153
+ if not source.is_absolute():
154
+ raise ValueError(
155
+ f"{_ENV_DB_PATH_MAPPING}: source path must be absolute, got {source!r}"
156
+ )
157
+ if not target.is_absolute():
158
+ raise ValueError(
159
+ f"{_ENV_DB_PATH_MAPPING}: target path must be absolute, got {target!r}"
160
+ )
161
+ mappings.append(DbPathMapping(source=source.resolve(), target=target.resolve()))
162
+ return mappings
163
+
164
+
165
+ def resolve_db_dir(project_root: Path) -> Path:
166
+ """Return the directory for database files given a project root.
167
+
168
+ Applies ``COCOINDEX_CODE_DB_PATH_MAPPING`` if set, otherwise falls back
169
+ to ``project_root / ".cocoindex_code"``.
170
+ """
171
+ global _db_path_mapping # noqa: PLW0603
172
+ if _db_path_mapping is None:
173
+ _db_path_mapping = _parse_db_path_mapping()
174
+
175
+ resolved = project_root.resolve()
176
+ for mapping in _db_path_mapping:
177
+ if resolved == mapping.source or resolved.is_relative_to(mapping.source):
178
+ rel = resolved.relative_to(mapping.source)
179
+ return mapping.target / rel
180
+ return project_root / _SETTINGS_DIR_NAME
181
+
182
+
183
+ def get_db_path_mappings() -> list[DbPathMapping]:
184
+ """Return the parsed DB path mappings from ``COCOINDEX_CODE_DB_PATH_MAPPING``."""
185
+ global _db_path_mapping # noqa: PLW0603
186
+ if _db_path_mapping is None:
187
+ _db_path_mapping = _parse_db_path_mapping()
188
+ return list(_db_path_mapping)
189
+
190
+
191
+ def _reset_db_path_mapping_cache() -> None:
192
+ """Reset the cached mapping (for tests)."""
193
+ global _db_path_mapping # noqa: PLW0603
194
+ _db_path_mapping = None
195
+
118
196
 
119
197
  def user_settings_dir() -> Path:
120
198
  """Return ``~/.cocoindex_code/``.
121
199
 
122
200
  Respects ``COCOINDEX_CODE_DIR`` env var for overriding the base directory.
123
201
  """
124
- import os
125
-
126
202
  override = os.environ.get("COCOINDEX_CODE_DIR")
127
203
  if override:
128
204
  return Path(override)
@@ -240,7 +316,7 @@ def _user_settings_to_dict(settings: UserSettings) -> dict[str, Any]:
240
316
  def _user_settings_from_dict(d: dict[str, Any]) -> UserSettings:
241
317
  emb_dict = d.get("embedding")
242
318
  if not emb_dict or "model" not in emb_dict:
243
- raise ValueError("global_settings.yml must contain 'embedding' with at least 'model' field")
319
+ raise ValueError("Must contain 'embedding' with at least 'model' field")
244
320
  # Only pass keys that are present; provider uses dataclass default ("litellm") if omitted
245
321
  emb_kwargs: dict[str, Any] = {"model": emb_dict["model"]}
246
322
  if "provider" in emb_dict:
@@ -288,11 +364,14 @@ def load_user_settings() -> UserSettings:
288
364
  path = user_settings_path()
289
365
  if not path.is_file():
290
366
  raise FileNotFoundError(f"User settings not found: {path}")
291
- with open(path) as f:
292
- data = _yaml.safe_load(f)
293
- if not data:
294
- raise ValueError(f"User settings file is empty: {path}")
295
- return _user_settings_from_dict(data)
367
+ try:
368
+ with open(path) as f:
369
+ data = _yaml.safe_load(f)
370
+ if not data:
371
+ raise ValueError("File is empty")
372
+ return _user_settings_from_dict(data)
373
+ except Exception as e:
374
+ raise type(e)(f"Error loading {path}: {e}") from e
296
375
 
297
376
 
298
377
  def save_user_settings(settings: UserSettings) -> Path:
@@ -312,11 +391,14 @@ def load_project_settings(project_root: Path) -> ProjectSettings:
312
391
  path = project_settings_path(project_root)
313
392
  if not path.is_file():
314
393
  raise FileNotFoundError(f"Project settings not found: {path}")
315
- with open(path) as f:
316
- data = _yaml.safe_load(f)
317
- if not data:
318
- return default_project_settings()
319
- return _project_settings_from_dict(data)
394
+ try:
395
+ with open(path) as f:
396
+ data = _yaml.safe_load(f)
397
+ if not data:
398
+ return default_project_settings()
399
+ return _project_settings_from_dict(data)
400
+ except Exception as e:
401
+ raise type(e)(f"Error loading {path}: {e}") from e
320
402
 
321
403
 
322
404
  def save_project_settings(project_root: Path, settings: ProjectSettings) -> Path:
File without changes