cocoindex-code 0.2.5__tar.gz → 0.2.7__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.5 → cocoindex_code-0.2.7}/PKG-INFO +5 -2
  2. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/README.md +3 -0
  3. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/pyproject.toml +1 -1
  4. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/_version.py +2 -2
  5. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/cli.py +191 -15
  6. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/client.py +98 -10
  7. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/daemon.py +166 -4
  8. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/protocol.py +30 -0
  9. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/settings.py +17 -11
  10. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/.gitignore +0 -0
  11. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/LICENSE +0 -0
  12. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/__init__.py +0 -0
  13. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/__main__.py +0 -0
  14. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/config.py +0 -0
  15. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/indexer.py +0 -0
  16. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/project.py +0 -0
  17. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/query.py +0 -0
  18. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/schema.py +0 -0
  19. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/src/cocoindex_code/server.py +0 -0
  20. {cocoindex_code-0.2.5 → cocoindex_code-0.2.7}/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.5
3
+ Version: 0.2.7
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
@@ -17,7 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
19
  Requires-Python: >=3.11
20
- Requires-Dist: cocoindex[litellm]==1.0.0a35
20
+ Requires-Dist: cocoindex[litellm]==1.0.0a37
21
21
  Requires-Dist: einops>=0.8.2
22
22
  Requires-Dist: mcp>=1.0.0
23
23
  Requires-Dist: msgspec>=0.19.0
@@ -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 |
@@ -458,6 +459,8 @@ embedding:
458
459
 
459
460
  ## Troubleshooting
460
461
 
462
+ Run `ccc doctor` to diagnose common issues. It checks your settings, daemon health, embedding model, file matching, and index status — all in one command.
463
+
461
464
  ### `sqlite3.Connection object has no attribute enable_load_extension`
462
465
 
463
466
  Some Python installations (e.g. the one pre-installed on macOS) ship with a SQLite library that doesn't enable extensions.
@@ -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 |
@@ -419,6 +420,8 @@ embedding:
419
420
 
420
421
  ## Troubleshooting
421
422
 
423
+ Run `ccc doctor` to diagnose common issues. It checks your settings, daemon health, embedding model, file matching, and index status — all in one command.
424
+
422
425
  ### `sqlite3.Connection object has no attribute enable_load_extension`
423
426
 
424
427
  Some Python installations (e.g. the one pre-installed on macOS) ship with a SQLite library that doesn't enable extensions.
@@ -23,7 +23,7 @@ classifiers = [
23
23
 
24
24
  dependencies = [
25
25
  "mcp>=1.0.0",
26
- "cocoindex[litellm]==1.0.0a35",
26
+ "cocoindex[litellm]==1.0.0a37",
27
27
  "sentence-transformers>=2.2.0",
28
28
  "sqlite-vec>=0.1.0",
29
29
  "pydantic>=2.0.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.5'
32
- __version_tuple__ = version_tuple = (0, 2, 5)
31
+ __version__ = version = '0.2.7'
32
+ __version_tuple__ = version_tuple = (0, 2, 7)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -2,11 +2,15 @@
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
 
9
- from .protocol import IndexingProgress, ProjectStatusResponse, SearchResponse
12
+ from .client import DaemonStartError
13
+ from .protocol import DoctorCheckResult, IndexingProgress, ProjectStatusResponse, SearchResponse
10
14
  from .settings import (
11
15
  default_project_settings,
12
16
  default_user_settings,
@@ -35,8 +39,17 @@ app.add_typer(daemon_app, name="daemon")
35
39
  def require_project_root() -> Path:
36
40
  """Find the project root by walking up from CWD.
37
41
 
38
- Exits with code 1 if not found.
42
+ Checks global settings first (more fundamental), then project settings.
43
+ Exits with code 1 if either check fails.
39
44
  """
45
+ gs_path = user_settings_path()
46
+ if not gs_path.is_file():
47
+ _typer.echo(
48
+ f"Error: Global settings not found: {gs_path}\n"
49
+ "Run `ccc init` to create it with default settings.",
50
+ err=True,
51
+ )
52
+ raise _typer.Exit(code=1)
40
53
  root = find_project_root(Path.cwd())
41
54
  if root is None:
42
55
  _typer.echo(
@@ -48,6 +61,26 @@ def require_project_root() -> Path:
48
61
  return root
49
62
 
50
63
 
64
+ _F = TypeVar("_F", bound=Callable[..., object])
65
+
66
+
67
+ def _catch_daemon_start_error(func: _F) -> _F:
68
+ """Decorator that catches ``DaemonStartError`` and exits with a clean message.
69
+
70
+ Apply to any CLI command that may trigger daemon auto-start.
71
+ """
72
+
73
+ @functools.wraps(func)
74
+ def wrapper(*args: object, **kwargs: object) -> object:
75
+ try:
76
+ return func(*args, **kwargs)
77
+ except DaemonStartError as e:
78
+ _typer.echo(f"Error: {e}", err=True)
79
+ raise _typer.Exit(code=1)
80
+
81
+ return wrapper # type: ignore[return-value]
82
+
83
+
51
84
  def resolve_default_path(project_root: Path) -> str | None:
52
85
  """Compute default ``--path`` filter from CWD relative to project root."""
53
86
  cwd = Path.cwd().resolve()
@@ -138,6 +171,9 @@ def _run_index_with_progress(project_root: str) -> None:
138
171
  resp = _client.index(project_root, on_progress=_on_progress, on_waiting=_on_waiting)
139
172
  except RuntimeError as e:
140
173
  live.stop()
174
+ # Let DaemonStartError propagate to the decorator for consistent handling.
175
+ if isinstance(e, DaemonStartError):
176
+ raise
141
177
  _typer.echo(f"Indexing failed: {e}", err=True)
142
178
  raise _typer.Exit(code=1)
143
179
 
@@ -252,6 +288,12 @@ def init(
252
288
  cwd = Path.cwd().resolve()
253
289
  settings_file = _project_settings_path(cwd)
254
290
 
291
+ # Always ensure user settings exist
292
+ user_path = user_settings_path()
293
+ if not user_path.is_file():
294
+ save_user_settings(default_user_settings())
295
+ _typer.echo(f"Created user settings: {user_path}")
296
+
255
297
  # Check if already initialized
256
298
  if settings_file.is_file():
257
299
  _typer.echo("Project already initialized.")
@@ -268,12 +310,6 @@ def init(
268
310
  )
269
311
  raise _typer.Exit(code=1)
270
312
 
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
313
  # Create project settings
278
314
  save_project_settings(cwd, default_project_settings())
279
315
  _typer.echo(f"Created project settings: {settings_file}")
@@ -286,6 +322,7 @@ def init(
286
322
 
287
323
 
288
324
  @app.command()
325
+ @_catch_daemon_start_error
289
326
  def index() -> None:
290
327
  """Create/update index for the codebase."""
291
328
  from . import client as _client
@@ -297,6 +334,7 @@ def index() -> None:
297
334
 
298
335
 
299
336
  @app.command()
337
+ @_catch_daemon_start_error
300
338
  def search(
301
339
  query: list[str] = _typer.Argument(..., help="Search query"),
302
340
  lang: list[str] = _typer.Option([], "--lang", help="Filter by language"),
@@ -333,6 +371,7 @@ def search(
333
371
 
334
372
 
335
373
  @app.command()
374
+ @_catch_daemon_start_error
336
375
  def status() -> None:
337
376
  """Show project status."""
338
377
  from . import client as _client
@@ -415,7 +454,146 @@ def reset(
415
454
  )
416
455
 
417
456
 
457
+ def _print_section(name: str) -> None:
458
+ import click as _click
459
+
460
+ _typer.echo()
461
+ _typer.echo(_click.style(f" {name}", bold=True))
462
+ _typer.echo(_click.style(f" {'─' * 38}", fg="bright_black"))
463
+
464
+
465
+ def _print_error(msg: str) -> None:
466
+ import click as _click
467
+
468
+ _typer.echo(_click.style(f" ERROR: {msg}", fg="red"), err=True)
469
+
470
+
471
+ def _print_doctor_result(result: DoctorCheckResult) -> None:
472
+ import click as _click
473
+
474
+ if result.name == "done":
475
+ return
476
+ if result.ok:
477
+ tag = _click.style("[OK]", fg="green", bold=True)
478
+ else:
479
+ tag = _click.style("[FAIL]", fg="red", bold=True)
480
+ _typer.echo(f"\n {tag} {result.name}")
481
+ for line in result.details:
482
+ _typer.echo(f" {line}")
483
+ for err in result.errors:
484
+ _typer.echo(_click.style(f" ERROR: {err}", fg="red"), err=True)
485
+
486
+
487
+ @app.command()
488
+ @_catch_daemon_start_error
489
+ def doctor() -> None:
490
+ """Check system health and report issues."""
491
+ from . import client as _client
492
+ from .settings import (
493
+ load_project_settings as _load_project_settings,
494
+ )
495
+ from .settings import (
496
+ load_user_settings as _load_user_settings,
497
+ )
498
+ from .settings import (
499
+ project_settings_path as _project_settings_path,
500
+ )
501
+ from .settings import (
502
+ user_settings_path as _user_settings_path,
503
+ )
504
+
505
+ # --- 1. Global settings (local, no daemon needed) ---
506
+ _print_section("Global Settings")
507
+ settings_path = _user_settings_path()
508
+ _typer.echo(f" Settings: {settings_path}")
509
+ try:
510
+ user_settings = _load_user_settings()
511
+ emb = user_settings.embedding
512
+ device_str = f", device={emb.device}" if emb.device else ""
513
+ _typer.echo(f" Embedding: provider={emb.provider}, model={emb.model}{device_str}")
514
+ if user_settings.envs:
515
+ _typer.echo(
516
+ f" Env vars (from settings): {', '.join(sorted(user_settings.envs.keys()))}"
517
+ )
518
+ except (FileNotFoundError, ValueError) as e:
519
+ _print_error(str(e))
520
+
521
+ # --- 2. Connect to daemon (handshake with auto-start/restart) ---
522
+ _print_section("Daemon")
523
+ daemon_ok = False
524
+ try:
525
+ status = _client.daemon_status()
526
+ _typer.echo(f" Version: {status.version}")
527
+ _typer.echo(f" Uptime: {status.uptime_seconds:.1f}s")
528
+ _typer.echo(f" Loaded projects: {len(status.projects)}")
529
+ daemon_ok = True
530
+ except Exception as e:
531
+ _print_error(f"Cannot connect to daemon: {e}")
532
+ _typer.echo(" Remaining daemon-side checks will be skipped.")
533
+
534
+ # --- 3. Daemon environment (requires daemon) ---
535
+ if daemon_ok:
536
+ try:
537
+ env_resp = _client.daemon_env()
538
+ settings_keys = set(env_resp.settings_env_names)
539
+ other_keys = [k for k in env_resp.env_names if k not in settings_keys]
540
+ if other_keys:
541
+ _typer.echo(f" Other env vars in daemon: {', '.join(sorted(other_keys))}")
542
+ except Exception as e:
543
+ _print_error(f"Failed to get daemon env: {e}")
544
+
545
+ # --- 4. Model check (daemon-side, global — before project checks) ---
546
+ if daemon_ok:
547
+ try:
548
+ _client.doctor(
549
+ project_root=None,
550
+ on_result=_print_doctor_result,
551
+ )
552
+ except Exception as e:
553
+ _print_error(f"Model check failed: {e}")
554
+
555
+ # --- 5. Detect project ---
556
+ project_root = find_project_root(Path.cwd())
557
+
558
+ # --- 6. Project settings (local, no daemon needed) ---
559
+ if project_root is not None:
560
+ _print_section("Project Settings")
561
+ ps_path = _project_settings_path(project_root)
562
+ _typer.echo(f" Settings: {ps_path}")
563
+ try:
564
+ ps = _load_project_settings(project_root)
565
+ _typer.echo(f" Include patterns ({len(ps.include_patterns)}):")
566
+ _typer.echo(f" {', '.join(ps.include_patterns)}")
567
+ _typer.echo(f" Exclude patterns ({len(ps.exclude_patterns)}):")
568
+ _typer.echo(f" {', '.join(ps.exclude_patterns)}")
569
+ if ps.language_overrides:
570
+ _typer.echo(" Language overrides:")
571
+ for lo in ps.language_overrides:
572
+ _typer.echo(f" .{lo.ext} -> {lo.lang}")
573
+ except (FileNotFoundError, ValueError) as e:
574
+ _print_error(str(e))
575
+
576
+ # --- 7. Project daemon-side checks (file walk + index status) ---
577
+ if daemon_ok and project_root is not None:
578
+ try:
579
+ _client.doctor(
580
+ project_root=str(project_root),
581
+ on_result=_print_doctor_result,
582
+ )
583
+ except Exception as e:
584
+ _print_error(f"Project checks failed: {e}")
585
+
586
+ # --- 8. Log files ---
587
+ _print_section("Log Files")
588
+ from .daemon import daemon_dir as _daemon_dir
589
+
590
+ log_dir = _daemon_dir()
591
+ _typer.echo(f" Daemon logs: {log_dir / 'daemon.log'}")
592
+ _typer.echo(" Check logs above for further troubleshooting.")
593
+
594
+
418
595
  @app.command()
596
+ @_catch_daemon_start_error
419
597
  def mcp() -> None:
420
598
  """Run as MCP server (stdio mode)."""
421
599
  import asyncio
@@ -449,6 +627,7 @@ async def _bg_index(project_root: str) -> None:
449
627
 
450
628
 
451
629
  @daemon_app.command("status")
630
+ @_catch_daemon_start_error
452
631
  def daemon_status() -> None:
453
632
  """Show daemon status."""
454
633
  from . import client as _client
@@ -466,6 +645,7 @@ def daemon_status() -> None:
466
645
 
467
646
 
468
647
  @daemon_app.command("restart")
648
+ @_catch_daemon_start_error
469
649
  def daemon_restart() -> None:
470
650
  """Restart the daemon."""
471
651
  from .client import _wait_for_daemon, start_daemon, stop_daemon
@@ -474,13 +654,9 @@ def daemon_restart() -> None:
474
654
  stop_daemon()
475
655
 
476
656
  _typer.echo("Starting daemon...")
477
- start_daemon()
478
- try:
479
- _wait_for_daemon()
480
- _typer.echo("Daemon restarted.")
481
- except TimeoutError:
482
- _typer.echo("Error: Daemon did not start in time.", err=True)
483
- raise _typer.Exit(code=1)
657
+ proc = start_daemon()
658
+ _wait_for_daemon(proc=proc)
659
+ _typer.echo("Daemon restarted.")
484
660
 
485
661
 
486
662
  @daemon_app.command("stop")
@@ -18,9 +18,14 @@ 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
+ DaemonEnvRequest,
24
+ DaemonEnvResponse,
23
25
  DaemonStatusResponse,
26
+ DoctorCheckResult,
27
+ DoctorRequest,
28
+ DoctorResponse,
24
29
  ErrorResponse,
25
30
  HandshakeRequest,
26
31
  HandshakeResponse,
@@ -79,8 +84,8 @@ def _connect_and_handshake() -> Connection:
79
84
  except (ConnectionRefusedError, OSError):
80
85
  pass
81
86
 
82
- start_daemon()
83
- _wait_for_daemon()
87
+ proc = start_daemon()
88
+ _wait_for_daemon(proc=proc)
84
89
 
85
90
  # Verify the fresh daemon is reachable
86
91
  for _attempt in range(10):
@@ -140,6 +145,27 @@ class DaemonVersionError(RuntimeError):
140
145
  )
141
146
 
142
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
+
143
169
  def _send(req: Request) -> Response:
144
170
  """Open connection, handshake, send one request, read one response, close."""
145
171
  conn = _connect_and_handshake()
@@ -259,6 +285,41 @@ def stop() -> StopResponse:
259
285
  return _send(StopRequest()) # type: ignore[return-value]
260
286
 
261
287
 
288
+ def daemon_env() -> DaemonEnvResponse:
289
+ """Get environment variable names from the daemon."""
290
+ return _send(DaemonEnvRequest()) # type: ignore[return-value]
291
+
292
+
293
+ def doctor(
294
+ project_root: str | None = None,
295
+ on_result: Callable[[DoctorCheckResult], None] | None = None,
296
+ ) -> list[DoctorCheckResult]:
297
+ """Run doctor checks via daemon, streaming results to on_result callback."""
298
+ conn = _connect_and_handshake()
299
+ try:
300
+ conn.send_bytes(encode_request(DoctorRequest(project_root=project_root)))
301
+ results: list[DoctorCheckResult] = []
302
+ while True:
303
+ try:
304
+ data = conn.recv_bytes()
305
+ except EOFError:
306
+ raise RuntimeError("Connection to daemon lost during doctor checks")
307
+ resp = decode_response(data)
308
+ if isinstance(resp, ErrorResponse):
309
+ raise RuntimeError(f"Daemon error: {resp.message}")
310
+ if isinstance(resp, DoctorResponse):
311
+ results.append(resp.result)
312
+ if on_result is not None:
313
+ on_result(resp.result)
314
+ if resp.final:
315
+ break
316
+ else:
317
+ raise RuntimeError(f"Unexpected response: {type(resp).__name__}")
318
+ return results
319
+ finally:
320
+ conn.close()
321
+
322
+
262
323
  # ---------------------------------------------------------------------------
263
324
  # Daemon lifecycle helpers
264
325
  # ---------------------------------------------------------------------------
@@ -276,8 +337,12 @@ def is_daemon_running() -> bool:
276
337
  return os.path.exists(daemon_socket_path())
277
338
 
278
339
 
279
- def start_daemon() -> None:
280
- """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
+ """
281
346
  from .daemon import daemon_dir
282
347
 
283
348
  daemon_dir().mkdir(parents=True, exist_ok=True)
@@ -292,7 +357,7 @@ def start_daemon() -> None:
292
357
  log_fd = open(log_path, "w")
293
358
  if sys.platform == "win32":
294
359
  _create_no_window = 0x08000000
295
- subprocess.Popen(
360
+ proc = subprocess.Popen(
296
361
  cmd,
297
362
  stdout=log_fd,
298
363
  stderr=log_fd,
@@ -300,7 +365,7 @@ def start_daemon() -> None:
300
365
  creationflags=_create_no_window,
301
366
  )
302
367
  else:
303
- subprocess.Popen(
368
+ proc = subprocess.Popen(
304
369
  cmd,
305
370
  start_new_session=True,
306
371
  stdout=log_fd,
@@ -308,6 +373,7 @@ def start_daemon() -> None:
308
373
  stdin=subprocess.DEVNULL,
309
374
  )
310
375
  log_fd.close()
376
+ return proc
311
377
 
312
378
 
313
379
  def _find_ccc_executable() -> str | None:
@@ -429,11 +495,27 @@ def _cleanup_stale_files(pid_path: Path, pid: int | None) -> None:
429
495
  pass
430
496
 
431
497
 
432
- def _wait_for_daemon(timeout: float = 30.0) -> None:
433
- """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
+ """
434
508
  deadline = time.monotonic() + timeout
435
509
  sock_path = daemon_socket_path()
436
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
+
437
519
  if sys.platform == "win32":
438
520
  try:
439
521
  conn = Client(sock_path, family=_connection_family())
@@ -445,7 +527,13 @@ def _wait_for_daemon(timeout: float = 30.0) -> None:
445
527
  if os.path.exists(sock_path):
446
528
  return
447
529
  time.sleep(0.2)
448
- 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)
449
537
 
450
538
 
451
539
  def _needs_restart(resp: HandshakeResponse) -> bool:
@@ -17,9 +17,15 @@ from typing import Any
17
17
  from ._version import __version__
18
18
  from .project import Project
19
19
  from .protocol import (
20
+ DaemonEnvRequest,
21
+ DaemonEnvResponse,
20
22
  DaemonProjectInfo,
21
23
  DaemonStatusRequest,
22
24
  DaemonStatusResponse,
25
+ DoctorCheckResult,
26
+ DoctorRequest,
27
+ DoctorResponse,
28
+ DoctorStreamResponse,
23
29
  ErrorResponse,
24
30
  HandshakeRequest,
25
31
  HandshakeResponse,
@@ -151,6 +157,7 @@ async def handle_connection(
151
157
  start_time: float,
152
158
  on_shutdown: Callable[[], None],
153
159
  settings_mtime_us: int | None,
160
+ settings_env_names: list[str],
154
161
  ) -> None:
155
162
  """Handle a single client connection (per-request model).
156
163
 
@@ -186,7 +193,7 @@ async def handle_connection(
186
193
  data = await loop.run_in_executor(None, conn.recv_bytes)
187
194
  req = decode_request(data)
188
195
 
189
- result = await _dispatch(req, registry, start_time, on_shutdown)
196
+ result = await _dispatch(req, registry, start_time, on_shutdown, settings_env_names)
190
197
  if isinstance(result, AsyncIterator):
191
198
  try:
192
199
  async for resp in result:
@@ -231,17 +238,161 @@ async def _search_with_wait(
231
238
  yield ErrorResponse(message=str(e))
232
239
 
233
240
 
241
+ async def _handle_doctor(
242
+ req: DoctorRequest,
243
+ registry: ProjectRegistry,
244
+ ) -> AsyncIterator[DoctorStreamResponse]:
245
+ """Run doctor checks sequentially, yielding results as they complete.
246
+
247
+ When ``project_root`` is None, only the model check runs (global scope).
248
+ When ``project_root`` is set, only project-specific checks run (file walk + index status).
249
+ The CLI calls this twice — once without project, once with — so that global checks
250
+ appear before project settings in the output.
251
+ """
252
+ if req.project_root is None:
253
+ # Global-scope checks
254
+ yield DoctorResponse(result=await _check_model(registry._embedder))
255
+ else:
256
+ # Project-scope checks
257
+ yield DoctorResponse(result=await _check_file_walk(req.project_root))
258
+ yield DoctorResponse(result=await _check_index_status(req.project_root))
259
+
260
+ # Final marker
261
+ yield DoctorResponse(
262
+ result=DoctorCheckResult(name="done", ok=True, details=[], errors=[]),
263
+ final=True,
264
+ )
265
+
266
+
267
+ async def _check_model(embedder: Embedder) -> DoctorCheckResult:
268
+ """Test the embedding model by embedding a short string."""
269
+ try:
270
+ vec = await embedder.embed("hello world")
271
+ dim = len(vec)
272
+ return DoctorCheckResult(
273
+ name="Model Check",
274
+ ok=True,
275
+ details=[f"Embedding dimension: {dim}"],
276
+ errors=[],
277
+ )
278
+ except Exception as e:
279
+ return DoctorCheckResult(
280
+ name="Model Check",
281
+ ok=False,
282
+ details=[],
283
+ errors=[str(e)],
284
+ )
285
+
286
+
287
+ async def _check_file_walk(project_root_str: str) -> DoctorCheckResult:
288
+ """Walk project files and report counts + gitignore paths."""
289
+ from pathlib import PurePath
290
+
291
+ from cocoindex.resources.file import PatternFilePathMatcher
292
+
293
+ from .indexer import GitignoreAwareMatcher
294
+ from .settings import load_gitignore_spec, load_project_settings
295
+
296
+ project_root = Path(project_root_str)
297
+ try:
298
+ ps = load_project_settings(project_root)
299
+ except FileNotFoundError as e:
300
+ return DoctorCheckResult(name="File Walk", ok=False, details=[], errors=[str(e)])
301
+
302
+ gitignore_spec = load_gitignore_spec(project_root)
303
+ base_matcher = PatternFilePathMatcher(
304
+ included_patterns=ps.include_patterns,
305
+ excluded_patterns=ps.exclude_patterns,
306
+ )
307
+ matcher = GitignoreAwareMatcher(base_matcher, gitignore_spec, project_root)
308
+
309
+ counts_by_ext: dict[str, int] = {}
310
+ gitignore_dirs: list[str] = []
311
+ total = 0
312
+
313
+ def _walk() -> None:
314
+ nonlocal total
315
+ for dirpath_str, dirnames, filenames in os.walk(project_root):
316
+ dirpath = Path(dirpath_str)
317
+ rel_dir = PurePath(dirpath.relative_to(project_root))
318
+ if rel_dir != PurePath(".") and not matcher.is_dir_included(rel_dir):
319
+ dirnames.clear()
320
+ continue
321
+
322
+ if (dirpath / ".gitignore").is_file():
323
+ gitignore_dirs.append(str(rel_dir))
324
+
325
+ for fname in filenames:
326
+ rel_path = rel_dir / fname if rel_dir != PurePath(".") else PurePath(fname)
327
+ if matcher.is_file_included(rel_path):
328
+ total += 1
329
+ ext = PurePath(fname).suffix or "(no ext)"
330
+ counts_by_ext[ext] = counts_by_ext.get(ext, 0) + 1
331
+
332
+ await asyncio.get_event_loop().run_in_executor(None, _walk)
333
+
334
+ details = [f"Total matched files: {total}"]
335
+ for ext, count in sorted(counts_by_ext.items(), key=lambda x: -x[1]):
336
+ details.append(f" {ext}: {count}")
337
+ if gitignore_dirs:
338
+ details.append(f"Loaded .gitignore from: {', '.join(gitignore_dirs)}")
339
+
340
+ return DoctorCheckResult(name="File Walk", ok=True, details=details, errors=[])
341
+
342
+
343
+ async def _check_index_status(project_root_str: str) -> DoctorCheckResult:
344
+ """Check index status by querying target_sqlite.db directly."""
345
+ from cocoindex.connectors import sqlite as coco_sqlite
346
+
347
+ project_root = Path(project_root_str)
348
+ db_path = project_root / ".cocoindex_code" / "target_sqlite.db"
349
+ details = [f"Index: {db_path}"]
350
+
351
+ if not db_path.exists():
352
+ details.append("Index not created yet.")
353
+ return DoctorCheckResult(name="Index Status", ok=True, details=details, errors=[])
354
+
355
+ try:
356
+ conn = coco_sqlite.connect(str(db_path), load_vec=True)
357
+ try:
358
+ with conn.readonly() as db:
359
+ total_chunks = db.execute("SELECT COUNT(*) FROM code_chunks_vec").fetchone()[0]
360
+ file_rows = db.execute("SELECT DISTINCT file_path FROM code_chunks_vec").fetchall()
361
+ total_files = len(file_rows)
362
+ lang_rows = db.execute(
363
+ "SELECT language, COUNT(*) FROM code_chunks_vec GROUP BY language"
364
+ ).fetchall()
365
+ languages = {row[0]: row[1] for row in lang_rows}
366
+ finally:
367
+ conn.close()
368
+
369
+ details.append(f"Chunks: {total_chunks}")
370
+ details.append(f"Files: {total_files}")
371
+ if languages:
372
+ details.append("Languages:")
373
+ for lang, count in sorted(languages.items(), key=lambda x: -x[1]):
374
+ details.append(f" {lang}: {count} chunks")
375
+ return DoctorCheckResult(name="Index Status", ok=True, details=details, errors=[])
376
+ except Exception as e:
377
+ return DoctorCheckResult(name="Index Status", ok=False, details=details, errors=[str(e)])
378
+
379
+
234
380
  async def _dispatch(
235
381
  req: Request,
236
382
  registry: ProjectRegistry,
237
383
  start_time: float,
238
384
  on_shutdown: Callable[[], None],
239
- ) -> Response | AsyncIterator[IndexStreamResponse] | AsyncIterator[SearchStreamResponse]:
385
+ settings_env_names: list[str],
386
+ ) -> (
387
+ Response
388
+ | AsyncIterator[IndexStreamResponse]
389
+ | AsyncIterator[SearchStreamResponse]
390
+ | AsyncIterator[DoctorStreamResponse]
391
+ ):
240
392
  """Dispatch a request to the appropriate handler.
241
393
 
242
394
  Returns a single Response for most requests, or an AsyncIterator for
243
- streaming requests (IndexRequest, or SearchRequest when waiting for
244
- load-time indexing).
395
+ streaming requests (IndexRequest, SearchRequest when waiting, DoctorRequest).
245
396
  """
246
397
  try:
247
398
  if isinstance(req, IndexRequest):
@@ -289,6 +440,15 @@ async def _dispatch(
289
440
  on_shutdown()
290
441
  return StopResponse(ok=True)
291
442
 
443
+ if isinstance(req, DaemonEnvRequest):
444
+ return DaemonEnvResponse(
445
+ env_names=sorted(os.environ.keys()),
446
+ settings_env_names=settings_env_names,
447
+ )
448
+
449
+ if isinstance(req, DoctorRequest):
450
+ return _handle_doctor(req, registry)
451
+
292
452
  return ErrorResponse(message=f"Unknown request type: {type(req).__name__}")
293
453
  except Exception as e:
294
454
  logger.exception("Error dispatching request")
@@ -314,6 +474,7 @@ def run_daemon() -> None:
314
474
  settings_mtime_us = global_settings_mtime_us()
315
475
 
316
476
  # Set environment variables from settings
477
+ settings_env_keys = list(user_settings.envs.keys())
317
478
  for key, value in user_settings.envs.items():
318
479
  os.environ[key] = value
319
480
 
@@ -363,6 +524,7 @@ def run_daemon() -> None:
363
524
  start_time,
364
525
  _request_shutdown,
365
526
  settings_mtime_us,
527
+ settings_env_keys,
366
528
  )
367
529
  )
368
530
  tasks.add(task)
@@ -42,6 +42,14 @@ class StopRequest(_msgspec.Struct, tag="stop"):
42
42
  pass
43
43
 
44
44
 
45
+ class DoctorRequest(_msgspec.Struct, tag="doctor"):
46
+ project_root: str | None = None
47
+
48
+
49
+ class DaemonEnvRequest(_msgspec.Struct, tag="daemon_env"):
50
+ pass
51
+
52
+
45
53
  Request = (
46
54
  HandshakeRequest
47
55
  | IndexRequest
@@ -50,6 +58,8 @@ Request = (
50
58
  | DaemonStatusRequest
51
59
  | RemoveProjectRequest
52
60
  | StopRequest
61
+ | DoctorRequest
62
+ | DaemonEnvRequest
53
63
  )
54
64
 
55
65
  # ---------------------------------------------------------------------------
@@ -136,6 +146,23 @@ class StopResponse(_msgspec.Struct, tag="stop"):
136
146
  ok: bool
137
147
 
138
148
 
149
+ class DoctorCheckResult(_msgspec.Struct):
150
+ name: str
151
+ ok: bool
152
+ details: list[str]
153
+ errors: list[str]
154
+
155
+
156
+ class DoctorResponse(_msgspec.Struct, tag="doctor"):
157
+ result: DoctorCheckResult
158
+ final: bool = False
159
+
160
+
161
+ class DaemonEnvResponse(_msgspec.Struct, tag="daemon_env"):
162
+ env_names: list[str]
163
+ settings_env_names: list[str]
164
+
165
+
139
166
  class ErrorResponse(_msgspec.Struct, tag="error"):
140
167
  message: str
141
168
 
@@ -150,11 +177,14 @@ Response = (
150
177
  | DaemonStatusResponse
151
178
  | RemoveProjectResponse
152
179
  | StopResponse
180
+ | DoctorResponse
181
+ | DaemonEnvResponse
153
182
  | ErrorResponse
154
183
  )
155
184
 
156
185
  IndexStreamResponse = IndexProgressUpdate | IndexWaitingNotice | IndexResponse | ErrorResponse
157
186
  SearchStreamResponse = IndexWaitingNotice | SearchResponse | ErrorResponse
187
+ DoctorStreamResponse = DoctorResponse | ErrorResponse
158
188
 
159
189
  # ---------------------------------------------------------------------------
160
190
  # Encode / decode helpers (msgpack binary)
@@ -240,7 +240,7 @@ def _user_settings_to_dict(settings: UserSettings) -> dict[str, Any]:
240
240
  def _user_settings_from_dict(d: dict[str, Any]) -> UserSettings:
241
241
  emb_dict = d.get("embedding")
242
242
  if not emb_dict or "model" not in emb_dict:
243
- raise ValueError("global_settings.yml must contain 'embedding' with at least 'model' field")
243
+ raise ValueError("Must contain 'embedding' with at least 'model' field")
244
244
  # Only pass keys that are present; provider uses dataclass default ("litellm") if omitted
245
245
  emb_kwargs: dict[str, Any] = {"model": emb_dict["model"]}
246
246
  if "provider" in emb_dict:
@@ -288,11 +288,14 @@ def load_user_settings() -> UserSettings:
288
288
  path = user_settings_path()
289
289
  if not path.is_file():
290
290
  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)
291
+ try:
292
+ with open(path) as f:
293
+ data = _yaml.safe_load(f)
294
+ if not data:
295
+ raise ValueError("File is empty")
296
+ return _user_settings_from_dict(data)
297
+ except Exception as e:
298
+ raise type(e)(f"Error loading {path}: {e}") from e
296
299
 
297
300
 
298
301
  def save_user_settings(settings: UserSettings) -> Path:
@@ -312,11 +315,14 @@ def load_project_settings(project_root: Path) -> ProjectSettings:
312
315
  path = project_settings_path(project_root)
313
316
  if not path.is_file():
314
317
  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)
318
+ try:
319
+ with open(path) as f:
320
+ data = _yaml.safe_load(f)
321
+ if not data:
322
+ return default_project_settings()
323
+ return _project_settings_from_dict(data)
324
+ except Exception as e:
325
+ raise type(e)(f"Error loading {path}: {e}") from e
320
326
 
321
327
 
322
328
  def save_project_settings(project_root: Path, settings: ProjectSettings) -> Path:
File without changes