Skip to content
267 changes: 224 additions & 43 deletions src/cocoindex_code/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@


# ---------------------------------------------------------------------------
# Shared CLI helpers (G1)
# Shared CLI helpers
# ---------------------------------------------------------------------------


Expand Down Expand Up @@ -70,7 +70,7 @@ def require_daemon_for_project() -> tuple[DaemonClient, str]:

def resolve_default_path(project_root: Path) -> str | None:
"""Compute default ``--path`` filter from CWD relative to project root."""
cwd = Path.cwd()
cwd = Path.cwd().resolve()
try:
rel = cwd.relative_to(project_root)
except ValueError:
Expand Down Expand Up @@ -120,8 +120,126 @@ def print_search_results(response: SearchResponse) -> None:
_typer.echo(r.content)


def _run_index_with_progress(client: DaemonClient, project_root: str) -> None:
"""Run indexing with streaming progress display. Exits on failure."""
from rich.console import Console as _Console
from rich.live import Live as _Live
from rich.spinner import Spinner as _Spinner

err_console = _Console(stderr=True)
last_progress_line: str | None = None

with _Live(_Spinner("dots", "Indexing..."), console=err_console, transient=True) as live:

def _on_waiting() -> None:
live.update(
_Spinner(
"dots",
"Another indexing is ongoing, waiting for it to finish...",
)
)

def _on_progress(progress: IndexingProgress) -> None:
nonlocal last_progress_line
last_progress_line = f"Indexing: {_format_progress(progress)}"
live.update(_Spinner("dots", last_progress_line))

try:
resp = client.index(project_root, on_progress=_on_progress, on_waiting=_on_waiting)
except RuntimeError as e:
live.stop()
_typer.echo(f"Indexing failed: {e}", err=True)
raise _typer.Exit(code=1)

# Print the final progress line so it remains visible after the spinner clears
if last_progress_line is not None:
_typer.echo(last_progress_line, err=True)

if not resp.success:
_typer.echo(f"Indexing failed: {resp.message}", err=True)
raise _typer.Exit(code=1)


_GITIGNORE_COMMENT = "# cocoindex-code"
_GITIGNORE_ENTRY = "/.cocoindex_code/"


def add_to_gitignore(project_root: Path) -> None:
"""Add ``/.cocoindex_code/`` to ``.gitignore`` if ``.git`` exists.

Creates ``.gitignore`` if it doesn't exist. Skips if the entry is already
present.
"""
if not (project_root / ".git").is_dir():
return

gitignore = project_root / ".gitignore"
if gitignore.is_file():
content = gitignore.read_text()
if _GITIGNORE_ENTRY in content.splitlines():
return # already present
# Ensure a trailing newline before appending
if content and not content.endswith("\n"):
content += "\n"
content += f"{_GITIGNORE_COMMENT}\n{_GITIGNORE_ENTRY}\n"
gitignore.write_text(content)
else:
gitignore.write_text(f"{_GITIGNORE_COMMENT}\n{_GITIGNORE_ENTRY}\n")


def remove_from_gitignore(project_root: Path) -> None:
"""Remove ``/.cocoindex_code/`` entry and its comment from ``.gitignore``."""
gitignore = project_root / ".gitignore"
if not gitignore.is_file():
return

lines = gitignore.read_text().splitlines(keepends=True)
new_lines: list[str] = []
i = 0
while i < len(lines):
stripped = lines[i].rstrip("\n\r")
if stripped == _GITIGNORE_ENTRY:
# Skip this line; also remove preceding comment if it matches
if new_lines and new_lines[-1].rstrip("\n\r") == _GITIGNORE_COMMENT:
new_lines.pop()
i += 1
continue
new_lines.append(lines[i])
i += 1
gitignore.write_text("".join(new_lines))


def auto_init_project() -> Path:
"""Auto-initialize project from CWD.

Runs core ``init`` logic without parent-directory confirmation and without
the "run ``ccc index``" prompt. Returns the project root (CWD).
"""
from .settings import project_settings_path

cwd = Path.cwd().resolve()
settings_file = project_settings_path(cwd)

if not settings_file.is_file():
# Create user settings if missing
user_path = user_settings_path()
if not user_path.is_file():
save_user_settings(default_user_settings())
_typer.echo(f"Created user settings: {user_path}")

# Create project settings
save_project_settings(cwd, default_project_settings())
_typer.echo(f"Created project settings: {settings_file}")
_typer.echo("You can edit the settings files to customize indexing behavior.")

# Update .gitignore
add_to_gitignore(cwd)

return cwd


# ---------------------------------------------------------------------------
# Commands (G2-G5)
# Commands
# ---------------------------------------------------------------------------


Expand All @@ -130,10 +248,10 @@ def init(
force: bool = _typer.Option(False, "-f", "--force", help="Skip parent directory warning"),
) -> None:
"""Initialize a project for cocoindex-code."""
from .settings import project_settings_path
from .settings import project_settings_path as _project_settings_path

cwd = Path.cwd()
settings_file = project_settings_path(cwd)
cwd = Path.cwd().resolve()
settings_file = _project_settings_path(cwd)

# Check if already initialized
if settings_file.is_file():
Expand All @@ -160,49 +278,32 @@ def init(
# Create project settings
save_project_settings(cwd, default_project_settings())
_typer.echo(f"Created project settings: {settings_file}")
_typer.echo("Project initialized. Run `ccc index` to build the index.")

# Add to .gitignore
add_to_gitignore(cwd)

_typer.echo("You can edit the settings files to customize indexing behavior.")
_typer.echo("Run `ccc index` to build the index.")


@app.command()
def index() -> None:
"""Create/update index for the codebase."""
from rich.console import Console as _Console
from rich.live import Live as _Live
from rich.spinner import Spinner as _Spinner

client, project_root = require_daemon_for_project()
err_console = _Console(stderr=True)
last_progress_line: str | None = None

with _Live(_Spinner("dots", "Indexing..."), console=err_console, transient=True) as live:

def _on_waiting() -> None:
live.update(
_Spinner(
"dots",
"Another indexing is ongoing, waiting for it to finish...",
)
)

def _on_progress(progress: IndexingProgress) -> None:
nonlocal last_progress_line
last_progress_line = f"Indexing: {_format_progress(progress)}"
live.update(_Spinner("dots", last_progress_line))

try:
resp = client.index(project_root, on_progress=_on_progress, on_waiting=_on_waiting)
except RuntimeError as e:
live.stop()
_typer.echo(f"Indexing failed: {e}", err=True)
raise _typer.Exit(code=1)
from .client import ensure_daemon

# Print the final progress line so it remains visible after the spinner clears
if last_progress_line is not None:
_typer.echo(last_progress_line, err=True)
# Auto-init if not in an initialized project
root = find_project_root(Path.cwd())
if root is None:
root = auto_init_project()

if not resp.success:
_typer.echo(f"Indexing failed: {resp.message}", err=True)
try:
client = ensure_daemon()
except Exception as e:
_typer.echo(f"Error: Failed to connect to daemon: {e}", err=True)
raise _typer.Exit(code=1)
project_root = str(root)

_run_index_with_progress(client, project_root)

status = client.project_status(project_root)
print_index_stats(status)
Expand All @@ -221,6 +322,10 @@ def search(
client, project_root = require_daemon_for_project()
query_str = " ".join(query)

# Refresh index with progress display before searching
if refresh:
_run_index_with_progress(client, project_root)

# Default path filter from CWD
paths: list[str] | None = None
if path is not None:
Expand All @@ -237,7 +342,7 @@ def search(
paths=paths,
limit=limit,
offset=offset,
refresh=refresh,
refresh=False,
)
print_search_results(resp)

Expand All @@ -250,6 +355,82 @@ def status() -> None:
print_index_stats(resp)


@app.command()
def reset(
all_: bool = _typer.Option(False, "--all", help="Also remove settings and .gitignore entry"),
force: bool = _typer.Option(False, "-f", "--force", help="Skip confirmation"),
) -> None:
"""Reset project databases and optionally remove settings."""
project_root = require_project_root()
cocoindex_dir = project_root / ".cocoindex_code"

db_files = [
cocoindex_dir / "cocoindex.db",
cocoindex_dir / "target_sqlite.db",
]
settings_file = cocoindex_dir / "settings.yml"

# Determine what will be deleted
to_delete = [f for f in db_files if f.exists()]
if all_:
if settings_file.exists():
to_delete.append(settings_file)

if not to_delete and not all_:
_typer.echo("Nothing to reset.")
return

# Show what will be deleted
if to_delete:
_typer.echo("The following files will be deleted:")
for f in to_delete:
_typer.echo(f" {f}")

# Confirm
if not force:
if not _typer.confirm("Proceed?"):
_typer.echo("Aborted.")
raise _typer.Exit(code=0)

# Remove project from daemon first so it releases file handles
try:
from .client import DaemonClient

client = DaemonClient.connect()
client.handshake()
client.remove_project(str(project_root))
client.close()
except (ConnectionRefusedError, OSError, RuntimeError):
pass # Daemon not running — that's fine

# Delete files/directories
import shutil as _shutil

for f in to_delete:
if f.is_dir():
_shutil.rmtree(f)
else:
f.unlink(missing_ok=True)

if all_:
# Remove .cocoindex_code/ if empty
try:
cocoindex_dir.rmdir()
except OSError:
pass # Not empty

# Remove from .gitignore
remove_from_gitignore(project_root)
_typer.echo("Project fully reset.")
else:
_typer.echo("Databases deleted.")
if settings_file.exists():
_typer.echo(
"Settings file still exists. Run `ccc reset --all` to remove it too,\n"
"or edit it manually."
)


@app.command()
def mcp() -> None:
"""Run as MCP server (stdio mode)."""
Expand Down Expand Up @@ -279,7 +460,7 @@ async def _bg_index(client, project_root: str) -> None: # type: ignore[no-untyp
pass


# --- Daemon subcommands (G5) ---
# --- Daemon subcommands ---


@daemon_app.command("status")
Expand Down
14 changes: 12 additions & 2 deletions src/cocoindex_code/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
IndexWaitingNotice,
ProjectStatusRequest,
ProjectStatusResponse,
RemoveProjectRequest,
RemoveProjectResponse,
Request,
Response,
SearchRequest,
Expand Down Expand Up @@ -72,7 +74,10 @@ def index(
"""Request indexing with streaming progress. Blocks until complete."""
self._conn.send_bytes(encode_request(IndexRequest(project_root=project_root)))
while True:
data = self._conn.recv_bytes()
try:
data = self._conn.recv_bytes()
except EOFError:
raise RuntimeError("Connection to daemon lost during indexing")
resp = decode_response(data)
if isinstance(resp, ErrorResponse):
raise RuntimeError(f"Daemon error: {resp.message}")
Expand Down Expand Up @@ -121,6 +126,11 @@ def daemon_status(self) -> DaemonStatusResponse:

return self._send(DaemonStatusRequest()) # type: ignore[return-value]

def remove_project(self, project_root: str) -> RemoveProjectResponse:
return self._send( # type: ignore[return-value]
RemoveProjectRequest(project_root=project_root)
)

def stop(self) -> StopResponse:
return self._send(StopRequest()) # type: ignore[return-value]

Expand Down Expand Up @@ -236,7 +246,7 @@ def stop_daemon() -> None:
pass


def _wait_for_daemon(timeout: float = 5.0) -> None:
def _wait_for_daemon(timeout: float = 10.0) -> None:
"""Wait for the daemon socket/pipe to become available."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
Expand Down
Loading
Loading