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.
- {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/.gitignore +5 -2
- {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/PKG-INFO +6 -4
- {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/README.md +0 -2
- {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/pyproject.toml +11 -1
- cocoindex_code-0.2.0/src/cocoindex_code/__init__.py +10 -0
- cocoindex_code-0.2.0/src/cocoindex_code/_version.py +34 -0
- cocoindex_code-0.2.0/src/cocoindex_code/cli.py +512 -0
- cocoindex_code-0.2.0/src/cocoindex_code/client.py +422 -0
- cocoindex_code-0.2.0/src/cocoindex_code/daemon.py +547 -0
- cocoindex_code-0.2.0/src/cocoindex_code/indexer.py +220 -0
- cocoindex_code-0.2.0/src/cocoindex_code/project.py +124 -0
- cocoindex_code-0.2.0/src/cocoindex_code/protocol.py +184 -0
- {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/src/cocoindex_code/query.py +12 -14
- cocoindex_code-0.2.0/src/cocoindex_code/server.py +341 -0
- cocoindex_code-0.2.0/src/cocoindex_code/settings.py +332 -0
- cocoindex_code-0.2.0/src/cocoindex_code/shared.py +88 -0
- cocoindex_code-0.1.14/src/cocoindex_code/__init__.py +0 -11
- cocoindex_code-0.1.14/src/cocoindex_code/indexer.py +0 -168
- cocoindex_code-0.1.14/src/cocoindex_code/server.py +0 -249
- cocoindex_code-0.1.14/src/cocoindex_code/shared.py +0 -88
- {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/LICENSE +0 -0
- {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/src/cocoindex_code/__main__.py +0 -0
- {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/src/cocoindex_code/config.py +0 -0
- {cocoindex_code-0.1.14 → cocoindex_code-0.2.0}/src/cocoindex_code/schema.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cocoindex-code
|
|
3
|
-
Version: 0.
|
|
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.
|
|
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.
|
|
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()
|