|
| 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