cocoindex-code 0.1.14__tar.gz → 0.2.0__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 (24) hide show
  1. {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/.gitignore +5 -2
  2. {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/PKG-INFO +6 -4
  3. {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/README.md +0 -2
  4. {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/pyproject.toml +11 -1
  5. cocoindex_code-0.2.0/src/cocoindex_code/__init__.py +10 -0
  6. cocoindex_code-0.2.0/src/cocoindex_code/_version.py +34 -0
  7. cocoindex_code-0.2.0/src/cocoindex_code/cli.py +512 -0
  8. cocoindex_code-0.2.0/src/cocoindex_code/client.py +422 -0
  9. cocoindex_code-0.2.0/src/cocoindex_code/daemon.py +547 -0
  10. cocoindex_code-0.2.0/src/cocoindex_code/indexer.py +220 -0
  11. cocoindex_code-0.2.0/src/cocoindex_code/project.py +124 -0
  12. cocoindex_code-0.2.0/src/cocoindex_code/protocol.py +184 -0
  13. {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/src/cocoindex_code/query.py +12 -14
  14. cocoindex_code-0.2.0/src/cocoindex_code/server.py +341 -0
  15. cocoindex_code-0.2.0/src/cocoindex_code/settings.py +332 -0
  16. cocoindex_code-0.2.0/src/cocoindex_code/shared.py +88 -0
  17. cocoindex_code-0.1.14/src/cocoindex_code/__init__.py +0 -11
  18. cocoindex_code-0.1.14/src/cocoindex_code/indexer.py +0 -168
  19. cocoindex_code-0.1.14/src/cocoindex_code/server.py +0 -249
  20. cocoindex_code-0.1.14/src/cocoindex_code/shared.py +0 -88
  21. {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/LICENSE +0 -0
  22. {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/src/cocoindex_code/__main__.py +0 -0
  23. {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/src/cocoindex_code/config.py +0 -0
  24. {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/src/cocoindex_code/schema.py +0 -0
@@ -41,5 +41,8 @@ htmlcov/
41
41
  .pytest_cache/
42
42
  .mypy_cache/
43
43
 
44
- # CocoIndex
45
- .cocoindex_code/
44
+ # Generated version file
45
+ src/cocoindex_code/_version.py
46
+
47
+ # CocoIndex Code (ccc)
48
+ /.cocoindex_code/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cocoindex-code
3
- Version: 0.1.14
3
+ Version: 0.2.0
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,13 +17,17 @@ 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.0a29
20
+ Requires-Dist: cocoindex[litellm]==1.0.0a32
21
21
  Requires-Dist: einops>=0.8.2
22
22
  Requires-Dist: mcp>=1.0.0
23
+ Requires-Dist: msgspec>=0.19.0
23
24
  Requires-Dist: numpy>=1.24.0
25
+ Requires-Dist: pathspec>=0.12.1
24
26
  Requires-Dist: pydantic>=2.0.0
27
+ Requires-Dist: pyyaml>=6.0
25
28
  Requires-Dist: sentence-transformers>=2.2.0
26
29
  Requires-Dist: sqlite-vec>=0.1.0
30
+ Requires-Dist: typer>=0.9.0
27
31
  Provides-Extra: dev
28
32
  Requires-Dist: mypy>=1.0.0; extra == 'dev'
29
33
  Requires-Dist: prek>=0.1.0; extra == 'dev'
@@ -163,7 +167,6 @@ Use the cocoindex-code MCP server for semantic code search when:
163
167
  |----------|-------------|---------|
164
168
  | `COCOINDEX_CODE_ROOT_PATH` | Root path of the codebase | Auto-discovered (see below) |
165
169
  | `COCOINDEX_CODE_EMBEDDING_MODEL` | Embedding model (see below) | `sbert/sentence-transformers/all-MiniLM-L6-v2` |
166
- | `COCOINDEX_CODE_BATCH_SIZE` | Max batch size for local embedding model | `16` |
167
170
  | `COCOINDEX_CODE_EXTRA_EXTENSIONS` | Additional file extensions to index (comma-separated, e.g. `"inc:php,yaml,toml"` — use `ext:lang` to override language detection) | _(none)_ |
168
171
  | `COCOINDEX_CODE_EXCLUDED_PATTERNS` | Additional glob patterns to exclude from indexing as a JSON array (e.g. `'["**/migration.sql", "{**/*.md,**/*.txt}"]'`) | _(none)_ |
169
172
 
@@ -316,7 +319,6 @@ claude mcp add cocoindex-code \
316
319
  ```bash
317
320
  claude mcp add cocoindex-code \
318
321
  -e COCOINDEX_CODE_EMBEDDING_MODEL=sbert/nomic-ai/CodeRankEmbed \
319
- -e COCOINDEX_CODE_BATCH_SIZE=16 \
320
322
  -- cocoindex-code
321
323
  ```
322
324
 
@@ -128,7 +128,6 @@ Use the cocoindex-code MCP server for semantic code search when:
128
128
  |----------|-------------|---------|
129
129
  | `COCOINDEX_CODE_ROOT_PATH` | Root path of the codebase | Auto-discovered (see below) |
130
130
  | `COCOINDEX_CODE_EMBEDDING_MODEL` | Embedding model (see below) | `sbert/sentence-transformers/all-MiniLM-L6-v2` |
131
- | `COCOINDEX_CODE_BATCH_SIZE` | Max batch size for local embedding model | `16` |
132
131
  | `COCOINDEX_CODE_EXTRA_EXTENSIONS` | Additional file extensions to index (comma-separated, e.g. `"inc:php,yaml,toml"` — use `ext:lang` to override language detection) | _(none)_ |
133
132
  | `COCOINDEX_CODE_EXCLUDED_PATTERNS` | Additional glob patterns to exclude from indexing as a JSON array (e.g. `'["**/migration.sql", "{**/*.md,**/*.txt}"]'`) | _(none)_ |
134
133
 
@@ -281,7 +280,6 @@ claude mcp add cocoindex-code \
281
280
  ```bash
282
281
  claude mcp add cocoindex-code \
283
282
  -e COCOINDEX_CODE_EMBEDDING_MODEL=sbert/nomic-ai/CodeRankEmbed \
284
- -e COCOINDEX_CODE_BATCH_SIZE=16 \
285
283
  -- cocoindex-code
286
284
  ```
287
285
 
@@ -23,12 +23,16 @@ classifiers = [
23
23
 
24
24
  dependencies = [
25
25
  "mcp>=1.0.0",
26
- "cocoindex[litellm]==1.0.0a29",
26
+ "cocoindex[litellm]==1.0.0a32",
27
27
  "sentence-transformers>=2.2.0",
28
28
  "sqlite-vec>=0.1.0",
29
29
  "pydantic>=2.0.0",
30
30
  "numpy>=1.24.0",
31
31
  "einops>=0.8.2",
32
+ "typer>=0.9.0",
33
+ "msgspec>=0.19.0",
34
+ "pathspec>=0.12.1",
35
+ "pyyaml>=6.0",
32
36
  ]
33
37
 
34
38
  [project.optional-dependencies]
@@ -43,6 +47,7 @@ dev = [
43
47
 
44
48
  [project.scripts]
45
49
  cocoindex-code = "cocoindex_code:main"
50
+ ccc = "cocoindex_code.cli:app"
46
51
 
47
52
  [project.urls]
48
53
  Homepage = "https://github.com/cocoindex-io/cocoindex-code"
@@ -51,6 +56,10 @@ Issues = "https://github.com/cocoindex-io/cocoindex-code/issues"
51
56
 
52
57
  [tool.hatch.version]
53
58
  source = "vcs"
59
+ fallback-version = "999.0.0"
60
+
61
+ [tool.hatch.build.hooks.vcs]
62
+ version-file = "src/cocoindex_code/_version.py"
54
63
 
55
64
  [tool.hatch.build.targets.wheel]
56
65
  packages = ["src/cocoindex_code"]
@@ -66,6 +75,7 @@ dev = [
66
75
  "ruff>=0.1.0",
67
76
  "mypy>=1.0.0",
68
77
  "prek>=0.1.0",
78
+ "types-pyyaml>=6.0.12.20250915",
69
79
  ]
70
80
 
71
81
  [tool.uv]
@@ -0,0 +1,10 @@
1
+ """CocoIndex Code - MCP server for indexing and querying codebases."""
2
+
3
+ import logging
4
+
5
+ logging.basicConfig(level=logging.WARNING)
6
+
7
+ from ._version import __version__ # noqa: E402
8
+ from .server import main # noqa: E402
9
+
10
+ __all__ = ["main", "__version__"]
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.2.0'
32
+ __version_tuple__ = version_tuple = (0, 2, 0)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,512 @@
1
+ """CLI entry point for cocoindex-code (ccc command)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING
7
+
8
+ import typer as _typer
9
+
10
+ if TYPE_CHECKING:
11
+ from .client import DaemonClient
12
+
13
+ from .protocol import IndexingProgress, ProjectStatusResponse, SearchResponse
14
+ from .settings import (
15
+ default_project_settings,
16
+ default_user_settings,
17
+ find_parent_with_marker,
18
+ find_project_root,
19
+ save_project_settings,
20
+ save_user_settings,
21
+ user_settings_path,
22
+ )
23
+
24
+ app = _typer.Typer(
25
+ name="ccc",
26
+ help="CocoIndex Code — index and search codebases.",
27
+ no_args_is_help=True,
28
+ )
29
+
30
+ daemon_app = _typer.Typer(name="daemon", help="Manage the daemon process.")
31
+ app.add_typer(daemon_app, name="daemon")
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Shared CLI helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ def require_project_root() -> Path:
40
+ """Find the project root by walking up from CWD.
41
+
42
+ Exits with code 1 if not found.
43
+ """
44
+ root = find_project_root(Path.cwd())
45
+ if root is None:
46
+ _typer.echo(
47
+ "Error: Not in an initialized project directory.\n"
48
+ "Run `ccc init` in your project root to get started.",
49
+ err=True,
50
+ )
51
+ raise _typer.Exit(code=1)
52
+ return root
53
+
54
+
55
+ def require_daemon_for_project() -> tuple[DaemonClient, str]:
56
+ """Resolve project root, then connect to daemon (auto-starting if needed).
57
+
58
+ Returns ``(client, project_root_str)``. Exits on failure.
59
+ """
60
+ from .client import ensure_daemon
61
+
62
+ project_root = require_project_root()
63
+ try:
64
+ client = ensure_daemon()
65
+ except Exception as e:
66
+ _typer.echo(f"Error: Failed to connect to daemon: {e}", err=True)
67
+ raise _typer.Exit(code=1)
68
+ return client, str(project_root)
69
+
70
+
71
+ def resolve_default_path(project_root: Path) -> str | None:
72
+ """Compute default ``--path`` filter from CWD relative to project root."""
73
+ cwd = Path.cwd().resolve()
74
+ try:
75
+ rel = cwd.relative_to(project_root)
76
+ except ValueError:
77
+ return None
78
+ if rel == Path("."):
79
+ return None
80
+ return f"{rel.as_posix()}/*"
81
+
82
+
83
+ def _format_progress(progress: IndexingProgress) -> str:
84
+ """Format an IndexingProgress snapshot as a human-readable string."""
85
+ return (
86
+ f"{progress.num_execution_starts} files listed"
87
+ f" | {progress.num_adds} added, {progress.num_deletes} deleted,"
88
+ f" {progress.num_reprocesses} reprocessed,"
89
+ f" {progress.num_unchanged} unchanged,"
90
+ f" error: {progress.num_errors}"
91
+ )
92
+
93
+
94
+ def print_project_header(project_root: str) -> None:
95
+ """Print the project root directory."""
96
+ _typer.echo(f"Project: {project_root}")
97
+
98
+
99
+ def print_index_stats(status: ProjectStatusResponse) -> None:
100
+ """Print formatted index statistics."""
101
+ if status.progress is not None:
102
+ _typer.echo(f"Indexing in progress: {_format_progress(status.progress)}")
103
+ _typer.echo("\nIndex stats:")
104
+ _typer.echo(f" Chunks: {status.total_chunks}")
105
+ _typer.echo(f" Files: {status.total_files}")
106
+ if status.languages:
107
+ _typer.echo(" Languages:")
108
+ for lang, count in sorted(status.languages.items(), key=lambda x: -x[1]):
109
+ _typer.echo(f" {lang}: {count} chunks")
110
+
111
+
112
+ def print_search_results(response: SearchResponse) -> None:
113
+ """Print formatted search results."""
114
+ if not response.success:
115
+ _typer.echo(f"Search failed: {response.message}", err=True)
116
+ return
117
+
118
+ if not response.results:
119
+ _typer.echo("No results found.")
120
+ return
121
+
122
+ for i, r in enumerate(response.results, 1):
123
+ _typer.echo(f"\n--- Result {i} (score: {r.score:.3f}) ---")
124
+ _typer.echo(f"File: {r.file_path}:{r.start_line}-{r.end_line} [{r.language}]")
125
+ _typer.echo(r.content)
126
+
127
+
128
+ def _run_index_with_progress(client: DaemonClient, project_root: str) -> None:
129
+ """Run indexing with streaming progress display. Exits on failure."""
130
+ from rich.console import Console as _Console
131
+ from rich.live import Live as _Live
132
+ from rich.spinner import Spinner as _Spinner
133
+
134
+ err_console = _Console(stderr=True)
135
+ last_progress_line: str | None = None
136
+
137
+ with _Live(_Spinner("dots", "Indexing..."), console=err_console, transient=True) as live:
138
+
139
+ def _on_waiting() -> None:
140
+ live.update(
141
+ _Spinner(
142
+ "dots",
143
+ "Another indexing is ongoing, waiting for it to finish...",
144
+ )
145
+ )
146
+
147
+ def _on_progress(progress: IndexingProgress) -> None:
148
+ nonlocal last_progress_line
149
+ last_progress_line = f"Indexing: {_format_progress(progress)}"
150
+ live.update(_Spinner("dots", last_progress_line))
151
+
152
+ try:
153
+ resp = client.index(project_root, on_progress=_on_progress, on_waiting=_on_waiting)
154
+ except RuntimeError as e:
155
+ live.stop()
156
+ _typer.echo(f"Indexing failed: {e}", err=True)
157
+ raise _typer.Exit(code=1)
158
+
159
+ # Print the final progress line so it remains visible after the spinner clears
160
+ if last_progress_line is not None:
161
+ _typer.echo(last_progress_line, err=True)
162
+
163
+ if not resp.success:
164
+ _typer.echo(f"Indexing failed: {resp.message}", err=True)
165
+ raise _typer.Exit(code=1)
166
+
167
+
168
+ _GITIGNORE_COMMENT = "# CocoIndex Code (ccc)"
169
+ _GITIGNORE_ENTRY = "/.cocoindex_code/"
170
+
171
+
172
+ def add_to_gitignore(project_root: Path) -> None:
173
+ """Add ``/.cocoindex_code/`` to ``.gitignore`` if ``.git`` exists.
174
+
175
+ Creates ``.gitignore`` if it doesn't exist. Skips if the entry is already
176
+ present.
177
+ """
178
+ if not (project_root / ".git").is_dir():
179
+ return
180
+
181
+ gitignore = project_root / ".gitignore"
182
+ if gitignore.is_file():
183
+ content = gitignore.read_text()
184
+ if _GITIGNORE_ENTRY in content.splitlines():
185
+ return # already present
186
+ # Ensure a trailing newline before appending
187
+ if content and not content.endswith("\n"):
188
+ content += "\n"
189
+ content += f"{_GITIGNORE_COMMENT}\n{_GITIGNORE_ENTRY}\n"
190
+ gitignore.write_text(content)
191
+ else:
192
+ gitignore.write_text(f"{_GITIGNORE_COMMENT}\n{_GITIGNORE_ENTRY}\n")
193
+
194
+
195
+ def remove_from_gitignore(project_root: Path) -> None:
196
+ """Remove ``/.cocoindex_code/`` entry and its comment from ``.gitignore``."""
197
+ gitignore = project_root / ".gitignore"
198
+ if not gitignore.is_file():
199
+ return
200
+
201
+ lines = gitignore.read_text().splitlines(keepends=True)
202
+ new_lines: list[str] = []
203
+ i = 0
204
+ while i < len(lines):
205
+ stripped = lines[i].rstrip("\n\r")
206
+ if stripped == _GITIGNORE_ENTRY:
207
+ # Skip this line; also remove preceding comment if it matches
208
+ if new_lines and new_lines[-1].rstrip("\n\r") == _GITIGNORE_COMMENT:
209
+ new_lines.pop()
210
+ i += 1
211
+ continue
212
+ new_lines.append(lines[i])
213
+ i += 1
214
+ gitignore.write_text("".join(new_lines))
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Commands
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ @app.command()
223
+ def init(
224
+ force: bool = _typer.Option(False, "-f", "--force", help="Skip parent directory warning"),
225
+ ) -> None:
226
+ """Initialize a project for cocoindex-code."""
227
+ from .settings import project_settings_path as _project_settings_path
228
+
229
+ cwd = Path.cwd().resolve()
230
+ settings_file = _project_settings_path(cwd)
231
+
232
+ # Check if already initialized
233
+ if settings_file.is_file():
234
+ _typer.echo("Project already initialized.")
235
+ return
236
+
237
+ # Check parent directories for markers
238
+ if not force:
239
+ parent = find_parent_with_marker(cwd)
240
+ if parent is not None and parent != cwd:
241
+ _typer.echo(
242
+ f"Warning: A parent directory has a project marker: {parent}\n"
243
+ "You might want to run `ccc init` there instead.\n"
244
+ "Use `ccc init -f` to initialize here anyway."
245
+ )
246
+ raise _typer.Exit(code=1)
247
+
248
+ # Create user settings if missing
249
+ user_path = user_settings_path()
250
+ if not user_path.is_file():
251
+ save_user_settings(default_user_settings())
252
+ _typer.echo(f"Created user settings: {user_path}")
253
+
254
+ # Create project settings
255
+ save_project_settings(cwd, default_project_settings())
256
+ _typer.echo(f"Created project settings: {settings_file}")
257
+
258
+ # Add to .gitignore
259
+ add_to_gitignore(cwd)
260
+
261
+ _typer.echo("You can edit the settings files to customize indexing behavior.")
262
+ _typer.echo("Run `ccc index` to build the index.")
263
+
264
+
265
+ @app.command()
266
+ def index() -> None:
267
+ """Create/update index for the codebase."""
268
+ client, project_root = require_daemon_for_project()
269
+ print_project_header(project_root)
270
+
271
+ _run_index_with_progress(client, project_root)
272
+
273
+ status = client.project_status(project_root)
274
+ print_index_stats(status)
275
+
276
+
277
+ @app.command()
278
+ def search(
279
+ query: list[str] = _typer.Argument(..., help="Search query"),
280
+ lang: list[str] = _typer.Option([], "--lang", help="Filter by language"),
281
+ path: str | None = _typer.Option(None, "--path", help="Filter by file path glob"),
282
+ offset: int = _typer.Option(0, "--offset", help="Number of results to skip"),
283
+ limit: int = _typer.Option(10, "--limit", help="Maximum results to return"),
284
+ refresh: bool = _typer.Option(False, "--refresh", help="Refresh index before searching"),
285
+ ) -> None:
286
+ """Semantic search across the codebase."""
287
+ client, project_root = require_daemon_for_project()
288
+ query_str = " ".join(query)
289
+
290
+ # Refresh index with progress display before searching
291
+ if refresh:
292
+ _run_index_with_progress(client, project_root)
293
+
294
+ # Default path filter from CWD
295
+ paths: list[str] | None = None
296
+ if path is not None:
297
+ paths = [path]
298
+ else:
299
+ default = resolve_default_path(Path(project_root))
300
+ if default is not None:
301
+ paths = [default]
302
+
303
+ resp = client.search(
304
+ project_root=project_root,
305
+ query=query_str,
306
+ languages=lang or None,
307
+ paths=paths,
308
+ limit=limit,
309
+ offset=offset,
310
+ refresh=False,
311
+ )
312
+ print_search_results(resp)
313
+
314
+
315
+ @app.command()
316
+ def status() -> None:
317
+ """Show project status."""
318
+ client, project_root = require_daemon_for_project()
319
+ print_project_header(project_root)
320
+ resp = client.project_status(project_root)
321
+ print_index_stats(resp)
322
+
323
+
324
+ @app.command()
325
+ def reset(
326
+ all_: bool = _typer.Option(False, "--all", help="Also remove settings and .gitignore entry"),
327
+ force: bool = _typer.Option(False, "-f", "--force", help="Skip confirmation"),
328
+ ) -> None:
329
+ """Reset project databases and optionally remove settings."""
330
+ project_root = require_project_root()
331
+ cocoindex_dir = project_root / ".cocoindex_code"
332
+
333
+ db_files = [
334
+ cocoindex_dir / "cocoindex.db",
335
+ cocoindex_dir / "target_sqlite.db",
336
+ ]
337
+ settings_file = cocoindex_dir / "settings.yml"
338
+
339
+ # Determine what will be deleted
340
+ to_delete = [f for f in db_files if f.exists()]
341
+ if all_:
342
+ if settings_file.exists():
343
+ to_delete.append(settings_file)
344
+
345
+ if not to_delete and not all_:
346
+ _typer.echo("Nothing to reset.")
347
+ return
348
+
349
+ # Show what will be deleted
350
+ if to_delete:
351
+ _typer.echo("The following files will be deleted:")
352
+ for f in to_delete:
353
+ _typer.echo(f" {f}")
354
+
355
+ # Confirm
356
+ if not force:
357
+ if not _typer.confirm("Proceed?"):
358
+ _typer.echo("Aborted.")
359
+ raise _typer.Exit(code=0)
360
+
361
+ # Remove project from daemon first so it releases file handles
362
+ try:
363
+ from .client import DaemonClient
364
+
365
+ client = DaemonClient.connect()
366
+ client.handshake()
367
+ client.remove_project(str(project_root))
368
+ client.close()
369
+ except (ConnectionRefusedError, OSError, RuntimeError):
370
+ pass # Daemon not running — that's fine
371
+
372
+ # Delete files/directories
373
+ import shutil as _shutil
374
+
375
+ for f in to_delete:
376
+ if f.is_dir():
377
+ _shutil.rmtree(f)
378
+ else:
379
+ f.unlink(missing_ok=True)
380
+
381
+ if all_:
382
+ # Remove .cocoindex_code/ if empty
383
+ try:
384
+ cocoindex_dir.rmdir()
385
+ except OSError:
386
+ pass # Not empty
387
+
388
+ # Remove from .gitignore
389
+ remove_from_gitignore(project_root)
390
+ _typer.echo("Project fully reset.")
391
+ else:
392
+ _typer.echo("Databases deleted.")
393
+ if settings_file.exists():
394
+ _typer.echo(
395
+ "Settings file still exists. Run `ccc reset --all` to remove it too,\n"
396
+ "or edit it manually."
397
+ )
398
+
399
+
400
+ @app.command()
401
+ def mcp() -> None:
402
+ """Run as MCP server (stdio mode)."""
403
+ import asyncio
404
+
405
+ client, project_root = require_daemon_for_project()
406
+
407
+ async def _run_mcp() -> None:
408
+ from .server import create_mcp_server
409
+
410
+ mcp_server = create_mcp_server(client, project_root)
411
+ # Trigger initial indexing in background
412
+ asyncio.create_task(_bg_index(client, project_root))
413
+ await mcp_server.run_stdio_async()
414
+
415
+ asyncio.run(_run_mcp())
416
+
417
+
418
+ async def _bg_index(client, project_root: str) -> None: # type: ignore[no-untyped-def]
419
+ """Index in background, swallowing errors."""
420
+ import asyncio
421
+
422
+ loop = asyncio.get_event_loop()
423
+ try:
424
+ await loop.run_in_executor(None, client.index, project_root)
425
+ except Exception:
426
+ pass
427
+
428
+
429
+ # --- Daemon subcommands ---
430
+
431
+
432
+ @daemon_app.command("status")
433
+ def daemon_status() -> None:
434
+ """Show daemon status."""
435
+ from .client import ensure_daemon
436
+
437
+ try:
438
+ client = ensure_daemon()
439
+ except Exception as e:
440
+ _typer.echo(f"Error: {e}", err=True)
441
+ raise _typer.Exit(code=1)
442
+
443
+ resp = client.daemon_status()
444
+ _typer.echo(f"Daemon version: {resp.version}")
445
+ _typer.echo(f"Uptime: {resp.uptime_seconds:.1f}s")
446
+ if resp.projects:
447
+ _typer.echo("Projects:")
448
+ for p in resp.projects:
449
+ state = "indexing" if p.indexing else "idle"
450
+ _typer.echo(f" {p.project_root} [{state}]")
451
+ else:
452
+ _typer.echo("No projects loaded.")
453
+ client.close()
454
+
455
+
456
+ @daemon_app.command("restart")
457
+ def daemon_restart() -> None:
458
+ """Restart the daemon."""
459
+ from .client import _wait_for_daemon, start_daemon, stop_daemon
460
+
461
+ _typer.echo("Stopping daemon...")
462
+ stop_daemon()
463
+
464
+ _typer.echo("Starting daemon...")
465
+ start_daemon()
466
+ try:
467
+ _wait_for_daemon()
468
+ _typer.echo("Daemon restarted.")
469
+ except TimeoutError:
470
+ _typer.echo("Error: Daemon did not start in time.", err=True)
471
+ raise _typer.Exit(code=1)
472
+
473
+
474
+ @daemon_app.command("stop")
475
+ def daemon_stop() -> None:
476
+ """Stop the daemon."""
477
+ from .client import is_daemon_running, stop_daemon
478
+ from .daemon import daemon_pid_path
479
+
480
+ pid_path = daemon_pid_path()
481
+ if not pid_path.exists() and not is_daemon_running():
482
+ _typer.echo("Daemon is not running.")
483
+ return
484
+
485
+ stop_daemon()
486
+
487
+ # Wait for process to exit (check both pid file and socket)
488
+ import time
489
+
490
+ deadline = time.monotonic() + 5.0
491
+ while time.monotonic() < deadline:
492
+ if not pid_path.exists() and not is_daemon_running():
493
+ break
494
+ time.sleep(0.1)
495
+
496
+ if pid_path.exists() or is_daemon_running():
497
+ _typer.echo("Warning: daemon may not have stopped cleanly.", err=True)
498
+ else:
499
+ _typer.echo("Daemon stopped.")
500
+
501
+
502
+ @app.command("run-daemon", hidden=True)
503
+ def run_daemon_cmd() -> None:
504
+ """Internal: run the daemon process."""
505
+ from .daemon import run_daemon
506
+
507
+ run_daemon()
508
+
509
+
510
+ # Allow running as module: python -m cocoindex_code.cli
511
+ if __name__ == "__main__":
512
+ app()