3232
3333
3434# ---------------------------------------------------------------------------
35- # Shared CLI helpers (G1)
35+ # Shared CLI helpers
3636# ---------------------------------------------------------------------------
3737
3838
@@ -120,8 +120,126 @@ def print_search_results(response: SearchResponse) -> None:
120120 _typer .echo (r .content )
121121
122122
123+ def _run_index_with_progress (client : DaemonClient , project_root : str ) -> None :
124+ """Run indexing with streaming progress display. Exits on failure."""
125+ from rich .console import Console as _Console
126+ from rich .live import Live as _Live
127+ from rich .spinner import Spinner as _Spinner
128+
129+ err_console = _Console (stderr = True )
130+ last_progress_line : str | None = None
131+
132+ with _Live (_Spinner ("dots" , "Indexing..." ), console = err_console , transient = True ) as live :
133+
134+ def _on_waiting () -> None :
135+ live .update (
136+ _Spinner (
137+ "dots" ,
138+ "Another indexing is ongoing, waiting for it to finish..." ,
139+ )
140+ )
141+
142+ def _on_progress (progress : IndexingProgress ) -> None :
143+ nonlocal last_progress_line
144+ last_progress_line = f"Indexing: { _format_progress (progress )} "
145+ live .update (_Spinner ("dots" , last_progress_line ))
146+
147+ try :
148+ resp = client .index (project_root , on_progress = _on_progress , on_waiting = _on_waiting )
149+ except RuntimeError as e :
150+ live .stop ()
151+ _typer .echo (f"Indexing failed: { e } " , err = True )
152+ raise _typer .Exit (code = 1 )
153+
154+ # Print the final progress line so it remains visible after the spinner clears
155+ if last_progress_line is not None :
156+ _typer .echo (last_progress_line , err = True )
157+
158+ if not resp .success :
159+ _typer .echo (f"Indexing failed: { resp .message } " , err = True )
160+ raise _typer .Exit (code = 1 )
161+
162+
163+ _GITIGNORE_COMMENT = "# cocoindex-code"
164+ _GITIGNORE_ENTRY = "/.cocoindex_code/"
165+
166+
167+ def add_to_gitignore (project_root : Path ) -> None :
168+ """Add ``/.cocoindex_code/`` to ``.gitignore`` if ``.git`` exists.
169+
170+ Creates ``.gitignore`` if it doesn't exist. Skips if the entry is already
171+ present.
172+ """
173+ if not (project_root / ".git" ).is_dir ():
174+ return
175+
176+ gitignore = project_root / ".gitignore"
177+ if gitignore .is_file ():
178+ content = gitignore .read_text ()
179+ if _GITIGNORE_ENTRY in content .splitlines ():
180+ return # already present
181+ # Ensure a trailing newline before appending
182+ if content and not content .endswith ("\n " ):
183+ content += "\n "
184+ content += f"{ _GITIGNORE_COMMENT } \n { _GITIGNORE_ENTRY } \n "
185+ gitignore .write_text (content )
186+ else :
187+ gitignore .write_text (f"{ _GITIGNORE_COMMENT } \n { _GITIGNORE_ENTRY } \n " )
188+
189+
190+ def remove_from_gitignore (project_root : Path ) -> None :
191+ """Remove ``/.cocoindex_code/`` entry and its comment from ``.gitignore``."""
192+ gitignore = project_root / ".gitignore"
193+ if not gitignore .is_file ():
194+ return
195+
196+ lines = gitignore .read_text ().splitlines (keepends = True )
197+ new_lines : list [str ] = []
198+ i = 0
199+ while i < len (lines ):
200+ stripped = lines [i ].rstrip ("\n \r " )
201+ if stripped == _GITIGNORE_ENTRY :
202+ # Skip this line; also remove preceding comment if it matches
203+ if new_lines and new_lines [- 1 ].rstrip ("\n \r " ) == _GITIGNORE_COMMENT :
204+ new_lines .pop ()
205+ i += 1
206+ continue
207+ new_lines .append (lines [i ])
208+ i += 1
209+ gitignore .write_text ("" .join (new_lines ))
210+
211+
212+ def auto_init_project () -> Path :
213+ """Auto-initialize project from CWD.
214+
215+ Runs core ``init`` logic without parent-directory confirmation and without
216+ the "run ``ccc index``" prompt. Returns the project root (CWD).
217+ """
218+ from .settings import project_settings_path
219+
220+ cwd = Path .cwd ()
221+ settings_file = project_settings_path (cwd )
222+
223+ if not settings_file .is_file ():
224+ # Create user settings if missing
225+ user_path = user_settings_path ()
226+ if not user_path .is_file ():
227+ save_user_settings (default_user_settings ())
228+ _typer .echo (f"Created user settings: { user_path } " )
229+
230+ # Create project settings
231+ save_project_settings (cwd , default_project_settings ())
232+ _typer .echo (f"Created project settings: { settings_file } " )
233+ _typer .echo ("You can edit the settings files to customize indexing behavior." )
234+
235+ # Update .gitignore
236+ add_to_gitignore (cwd )
237+
238+ return cwd
239+
240+
123241# ---------------------------------------------------------------------------
124- # Commands (G2-G5)
242+ # Commands
125243# ---------------------------------------------------------------------------
126244
127245
@@ -130,10 +248,10 @@ def init(
130248 force : bool = _typer .Option (False , "-f" , "--force" , help = "Skip parent directory warning" ),
131249) -> None :
132250 """Initialize a project for cocoindex-code."""
133- from .settings import project_settings_path
251+ from .settings import project_settings_path as _project_settings_path
134252
135253 cwd = Path .cwd ()
136- settings_file = project_settings_path (cwd )
254+ settings_file = _project_settings_path (cwd )
137255
138256 # Check if already initialized
139257 if settings_file .is_file ():
@@ -160,49 +278,32 @@ def init(
160278 # Create project settings
161279 save_project_settings (cwd , default_project_settings ())
162280 _typer .echo (f"Created project settings: { settings_file } " )
163- _typer .echo ("Project initialized. Run `ccc index` to build the index." )
281+
282+ # Add to .gitignore
283+ add_to_gitignore (cwd )
284+
285+ _typer .echo ("You can edit the settings files to customize indexing behavior." )
286+ _typer .echo ("Run `ccc index` to build the index." )
164287
165288
166289@app .command ()
167290def index () -> None :
168291 """Create/update index for the codebase."""
169- from rich .console import Console as _Console
170- from rich .live import Live as _Live
171- from rich .spinner import Spinner as _Spinner
172-
173- client , project_root = require_daemon_for_project ()
174- err_console = _Console (stderr = True )
175- last_progress_line : str | None = None
176-
177- with _Live (_Spinner ("dots" , "Indexing..." ), console = err_console , transient = True ) as live :
178-
179- def _on_waiting () -> None :
180- live .update (
181- _Spinner (
182- "dots" ,
183- "Another indexing is ongoing, waiting for it to finish..." ,
184- )
185- )
186-
187- def _on_progress (progress : IndexingProgress ) -> None :
188- nonlocal last_progress_line
189- last_progress_line = f"Indexing: { _format_progress (progress )} "
190- live .update (_Spinner ("dots" , last_progress_line ))
191-
192- try :
193- resp = client .index (project_root , on_progress = _on_progress , on_waiting = _on_waiting )
194- except RuntimeError as e :
195- live .stop ()
196- _typer .echo (f"Indexing failed: { e } " , err = True )
197- raise _typer .Exit (code = 1 )
292+ from .client import ensure_daemon
198293
199- # Print the final progress line so it remains visible after the spinner clears
200- if last_progress_line is not None :
201- _typer .echo (last_progress_line , err = True )
294+ # Auto-init if not in an initialized project
295+ root = find_project_root (Path .cwd ())
296+ if root is None :
297+ root = auto_init_project ()
202298
203- if not resp .success :
204- _typer .echo (f"Indexing failed: { resp .message } " , err = True )
299+ try :
300+ client = ensure_daemon ()
301+ except Exception as e :
302+ _typer .echo (f"Error: Failed to connect to daemon: { e } " , err = True )
205303 raise _typer .Exit (code = 1 )
304+ project_root = str (root )
305+
306+ _run_index_with_progress (client , project_root )
206307
207308 status = client .project_status (project_root )
208309 print_index_stats (status )
@@ -221,6 +322,10 @@ def search(
221322 client , project_root = require_daemon_for_project ()
222323 query_str = " " .join (query )
223324
325+ # Refresh index with progress display before searching
326+ if refresh :
327+ _run_index_with_progress (client , project_root )
328+
224329 # Default path filter from CWD
225330 paths : list [str ] | None = None
226331 if path is not None :
@@ -237,7 +342,7 @@ def search(
237342 paths = paths ,
238343 limit = limit ,
239344 offset = offset ,
240- refresh = refresh ,
345+ refresh = False ,
241346 )
242347 print_search_results (resp )
243348
@@ -250,6 +355,77 @@ def status() -> None:
250355 print_index_stats (resp )
251356
252357
358+ @app .command ()
359+ def reset (
360+ all_ : bool = _typer .Option (False , "--all" , help = "Also remove settings and .gitignore entry" ),
361+ force : bool = _typer .Option (False , "-f" , "--force" , help = "Skip confirmation" ),
362+ ) -> None :
363+ """Reset project databases and optionally remove settings."""
364+ project_root = require_project_root ()
365+ cocoindex_dir = project_root / ".cocoindex_code"
366+
367+ db_files = [
368+ cocoindex_dir / "cocoindex.db" ,
369+ cocoindex_dir / "target_sqlite.db" ,
370+ ]
371+ settings_file = cocoindex_dir / "settings.yml"
372+
373+ # Determine what will be deleted
374+ to_delete = [f for f in db_files if f .exists ()]
375+ if all_ :
376+ if settings_file .exists ():
377+ to_delete .append (settings_file )
378+
379+ if not to_delete and not all_ :
380+ _typer .echo ("Nothing to reset." )
381+ return
382+
383+ # Show what will be deleted
384+ if to_delete :
385+ _typer .echo ("The following files will be deleted:" )
386+ for f in to_delete :
387+ _typer .echo (f" { f } " )
388+
389+ # Confirm
390+ if not force :
391+ if not _typer .confirm ("Proceed?" ):
392+ _typer .echo ("Aborted." )
393+ raise _typer .Exit (code = 0 )
394+
395+ # Delete files
396+ for f in to_delete :
397+ f .unlink (missing_ok = True )
398+
399+ # Remove project from daemon (without auto-starting)
400+ try :
401+ from .client import DaemonClient
402+
403+ client = DaemonClient .connect ()
404+ client .handshake ()
405+ client .remove_project (str (project_root ))
406+ client .close ()
407+ except (ConnectionRefusedError , OSError , RuntimeError ):
408+ pass # Daemon not running — that's fine
409+
410+ if all_ :
411+ # Remove .cocoindex_code/ if empty
412+ try :
413+ cocoindex_dir .rmdir ()
414+ except OSError :
415+ pass # Not empty
416+
417+ # Remove from .gitignore
418+ remove_from_gitignore (project_root )
419+ _typer .echo ("Project fully reset." )
420+ else :
421+ _typer .echo ("Databases deleted." )
422+ if settings_file .exists ():
423+ _typer .echo (
424+ "Settings file still exists. Run `ccc reset --all` to remove it too,\n "
425+ "or edit it manually."
426+ )
427+
428+
253429@app .command ()
254430def mcp () -> None :
255431 """Run as MCP server (stdio mode)."""
@@ -279,7 +455,7 @@ async def _bg_index(client, project_root: str) -> None: # type: ignore[no-untyp
279455 pass
280456
281457
282- # --- Daemon subcommands (G5) ---
458+ # --- Daemon subcommands ---
283459
284460
285461@daemon_app .command ("status" )
0 commit comments