Skip to content

Commit 0af3db0

Browse files
committed
feat: preliminary implementation for daemon + CLI
1 parent 8acb818 commit 0af3db0

24 files changed

Lines changed: 2896 additions & 668 deletions

.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ repos:
2525
hooks:
2626
- id: mypy
2727
name: mypy
28-
entry: uv run mypy src/
28+
entry: uv run mypy src/ tests/
2929
language: system
3030
types: [python]
3131
pass_filenames: false

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ This is built on top of [CocoIndex v1](https://cocoindex.io/docs-v1/llms.txt).
66
This project uses [uv](https://docs.astral.sh/uv/) for project management.
77

88
```bash
9-
uv run mypy # Type check Python code
10-
uv run pytest python/ # Run Python tests (use after both Rust and Python changes)
9+
uv run mypy . # Type check Python code
10+
uv run pytest tests/ # Run Python tests
1111
```
1212

1313
## Code Conventions

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,15 @@ classifiers = [
2323

2424
dependencies = [
2525
"mcp>=1.0.0",
26-
"cocoindex[litellm]==1.0.0a29",
26+
"cocoindex[litellm]==1.0.0a31",
2727
"sentence-transformers>=2.2.0",
2828
"sqlite-vec>=0.1.0",
2929
"pydantic>=2.0.0",
3030
"numpy>=1.24.0",
3131
"einops>=0.8.2",
32+
"typer>=0.9.0",
33+
"msgspec>=0.19.0",
34+
"pyyaml>=6.0",
3235
]
3336

3437
[project.optional-dependencies]
@@ -43,6 +46,7 @@ dev = [
4346

4447
[project.scripts]
4548
cocoindex-code = "cocoindex_code:main"
49+
ccc = "cocoindex_code.cli:app"
4650

4751
[project.urls]
4852
Homepage = "https://github.com/cocoindex-io/cocoindex-code"
@@ -66,6 +70,7 @@ dev = [
6670
"ruff>=0.1.0",
6771
"mypy>=1.0.0",
6872
"prek>=0.1.0",
73+
"types-pyyaml>=6.0.12.20250915",
6974
]
7075

7176
[tool.uv]

src/cocoindex_code/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
logging.basicConfig(level=logging.WARNING)
66

77
from ._version import __version__ # noqa: E402
8-
from .config import Config # noqa: E402
9-
from .server import main, mcp # noqa: E402
8+
from .server import main # noqa: E402
109

11-
__all__ = ["Config", "main", "mcp", "__version__"]
10+
__all__ = ["main", "__version__"]

src/cocoindex_code/cli.py

Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
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 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 (G1)
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()
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 print_index_stats(status: ProjectStatusResponse) -> None:
84+
"""Print formatted index statistics."""
85+
_typer.echo("\nIndex stats:")
86+
_typer.echo(f" Chunks: {status.total_chunks}")
87+
_typer.echo(f" Files: {status.total_files}")
88+
if status.languages:
89+
_typer.echo(" Languages:")
90+
for lang, count in sorted(status.languages.items(), key=lambda x: -x[1]):
91+
_typer.echo(f" {lang}: {count} chunks")
92+
93+
94+
def print_search_results(response: SearchResponse) -> None:
95+
"""Print formatted search results."""
96+
if not response.success:
97+
_typer.echo(f"Search failed: {response.message}", err=True)
98+
return
99+
100+
if not response.results:
101+
_typer.echo("No results found.")
102+
return
103+
104+
for i, r in enumerate(response.results, 1):
105+
_typer.echo(f"\n--- Result {i} (score: {r.score:.3f}) ---")
106+
_typer.echo(f"File: {r.file_path}:{r.start_line}-{r.end_line} [{r.language}]")
107+
_typer.echo(r.content)
108+
109+
110+
# ---------------------------------------------------------------------------
111+
# Commands (G2-G5)
112+
# ---------------------------------------------------------------------------
113+
114+
115+
@app.command()
116+
def init(
117+
force: bool = _typer.Option(False, "-f", "--force", help="Skip parent directory warning"),
118+
) -> None:
119+
"""Initialize a project for cocoindex-code."""
120+
from .settings import project_settings_path
121+
122+
cwd = Path.cwd()
123+
settings_file = project_settings_path(cwd)
124+
125+
# Check if already initialized
126+
if settings_file.is_file():
127+
_typer.echo("Project already initialized.")
128+
return
129+
130+
# Check parent directories for markers
131+
if not force:
132+
parent = find_parent_with_marker(cwd)
133+
if parent is not None and parent != cwd:
134+
_typer.echo(
135+
f"Warning: A parent directory has a project marker: {parent}\n"
136+
"You might want to run `ccc init` there instead.\n"
137+
"Use `ccc init -f` to initialize here anyway."
138+
)
139+
raise _typer.Exit(code=1)
140+
141+
# Create user settings if missing
142+
user_path = user_settings_path()
143+
if not user_path.is_file():
144+
save_user_settings(default_user_settings())
145+
_typer.echo(f"Created user settings: {user_path}")
146+
147+
# Create project settings
148+
save_project_settings(cwd, default_project_settings())
149+
_typer.echo(f"Created project settings: {settings_file}")
150+
_typer.echo("Project initialized. Run `ccc index` to build the index.")
151+
152+
153+
@app.command()
154+
def index() -> None:
155+
"""Create/update index for the codebase."""
156+
client, project_root = require_daemon_for_project()
157+
_typer.echo("Indexing...")
158+
try:
159+
resp = client.index(project_root)
160+
except RuntimeError as e:
161+
_typer.echo(f"Indexing failed: {e}", err=True)
162+
raise _typer.Exit(code=1)
163+
if not resp.success:
164+
_typer.echo(f"Indexing failed: {resp.message}", err=True)
165+
raise _typer.Exit(code=1)
166+
167+
status = client.project_status(project_root)
168+
print_index_stats(status)
169+
170+
171+
@app.command()
172+
def search(
173+
query: list[str] = _typer.Argument(..., help="Search query"),
174+
lang: list[str] = _typer.Option([], "--lang", help="Filter by language"),
175+
path: str | None = _typer.Option(None, "--path", help="Filter by file path glob"),
176+
offset: int = _typer.Option(0, "--offset", help="Number of results to skip"),
177+
limit: int = _typer.Option(10, "--limit", help="Maximum results to return"),
178+
refresh: bool = _typer.Option(False, "--refresh", help="Refresh index before searching"),
179+
) -> None:
180+
"""Semantic search across the codebase."""
181+
client, project_root = require_daemon_for_project()
182+
query_str = " ".join(query)
183+
184+
# Default path filter from CWD
185+
paths: list[str] | None = None
186+
if path is not None:
187+
paths = [path]
188+
else:
189+
default = resolve_default_path(Path(project_root))
190+
if default is not None:
191+
paths = [default]
192+
193+
resp = client.search(
194+
project_root=project_root,
195+
query=query_str,
196+
languages=lang or None,
197+
paths=paths,
198+
limit=limit,
199+
offset=offset,
200+
refresh=refresh,
201+
)
202+
print_search_results(resp)
203+
204+
205+
@app.command()
206+
def status() -> None:
207+
"""Show project status."""
208+
client, project_root = require_daemon_for_project()
209+
resp = client.project_status(project_root)
210+
print_index_stats(resp)
211+
212+
213+
@app.command()
214+
def mcp() -> None:
215+
"""Run as MCP server (stdio mode)."""
216+
import asyncio
217+
218+
client, project_root = require_daemon_for_project()
219+
220+
async def _run_mcp() -> None:
221+
from .server import create_mcp_server
222+
223+
mcp_server = create_mcp_server(client, project_root)
224+
# Trigger initial indexing in background
225+
asyncio.create_task(_bg_index(client, project_root))
226+
await mcp_server.run_stdio_async()
227+
228+
asyncio.run(_run_mcp())
229+
230+
231+
async def _bg_index(client, project_root: str) -> None: # type: ignore[no-untyped-def]
232+
"""Index in background, swallowing errors."""
233+
import asyncio
234+
235+
loop = asyncio.get_event_loop()
236+
try:
237+
await loop.run_in_executor(None, client.index, project_root)
238+
except Exception:
239+
pass
240+
241+
242+
# --- Daemon subcommands (G5) ---
243+
244+
245+
@daemon_app.command("status")
246+
def daemon_status() -> None:
247+
"""Show daemon status."""
248+
from .client import ensure_daemon
249+
250+
try:
251+
client = ensure_daemon()
252+
except Exception as e:
253+
_typer.echo(f"Error: {e}", err=True)
254+
raise _typer.Exit(code=1)
255+
256+
resp = client.daemon_status()
257+
_typer.echo(f"Daemon version: {resp.version}")
258+
_typer.echo(f"Uptime: {resp.uptime_seconds:.1f}s")
259+
if resp.projects:
260+
_typer.echo("Projects:")
261+
for p in resp.projects:
262+
state = "indexing" if p.indexing else "idle"
263+
_typer.echo(f" {p.project_root} [{state}]")
264+
else:
265+
_typer.echo("No projects loaded.")
266+
client.close()
267+
268+
269+
@daemon_app.command("restart")
270+
def daemon_restart() -> None:
271+
"""Restart the daemon."""
272+
from .client import _wait_for_daemon, start_daemon, stop_daemon
273+
274+
_typer.echo("Stopping daemon...")
275+
stop_daemon()
276+
277+
_typer.echo("Starting daemon...")
278+
start_daemon()
279+
try:
280+
_wait_for_daemon()
281+
_typer.echo("Daemon restarted.")
282+
except TimeoutError:
283+
_typer.echo("Error: Daemon did not start in time.", err=True)
284+
raise _typer.Exit(code=1)
285+
286+
287+
@daemon_app.command("stop")
288+
def daemon_stop() -> None:
289+
"""Stop the daemon."""
290+
from .client import DaemonClient
291+
292+
try:
293+
client = DaemonClient.connect()
294+
client.handshake()
295+
client.stop()
296+
client.close()
297+
_typer.echo("Daemon stopped.")
298+
except (ConnectionRefusedError, OSError):
299+
_typer.echo("Daemon is not running.")
300+
301+
302+
@app.command("run-daemon", hidden=True)
303+
def run_daemon_cmd() -> None:
304+
"""Internal: run the daemon process."""
305+
from .daemon import run_daemon
306+
307+
run_daemon()
308+
309+
310+
# Allow running as module: python -m cocoindex_code.cli
311+
if __name__ == "__main__":
312+
app()

0 commit comments

Comments
 (0)