From d2299d267901d185182bbf352e55fa2ec45571c9 Mon Sep 17 00:00:00 2001 From: Andy Lee Date: Fri, 15 Aug 2025 23:11:52 -0700 Subject: [PATCH] feat: Enhance CLI with improved list and smart remove commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - **Better UX**: Current project shown first with clear separation - **Visual improvements**: Icons (šŸ /šŸ“‚), better formatting, size info - **Smart guidance**: Context-aware usage examples and getting started tips - **Safety first**: Always shows ALL matching indexes across projects - **Intelligent handling**: - Single match: Clear location display with cross-project warnings - Multiple matches: Interactive selection with final confirmation - **Prevents accidents**: No more deleting wrong indexes due to name conflicts - **User-friendly**: 'c' to cancel, clear visual hierarchy, detailed info - **Clean logging**: Hide debug messages for better CLI experience - **Comprehensive search**: Always scan all projects for transparency - **Error handling**: Graceful handling of edge cases and user input - **Safer**: Eliminates risk of accidental index deletion - **Clearer**: Users always know what they're operating on - **Smarter**: Automatic detection and handling of common scenarios --- README.md | 27 +- packages/leann-core/src/leann/cli.py | 329 +++++++++++++++++++--- packages/leann-core/src/leann/registry.py | 6 +- 3 files changed, 318 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 1eb5410..e0c4c52 100755 --- a/README.md +++ b/README.md @@ -484,6 +484,9 @@ leann ask my-docs --interactive # List all your indexes leann list + +# Remove an index +leann remove my-docs ``` **Key CLI features:** @@ -496,7 +499,7 @@ leann list
šŸ“‹ Click to expand: Complete CLI Reference -You can use `leann --help`, or `leann build --help`, `leann search --help`, `leann ask --help` to get the complete CLI reference. +You can use `leann --help`, or `leann build --help`, `leann search --help`, `leann ask --help`, `leann list --help`, `leann remove --help` to get the complete CLI reference. **Build Command:** ```bash @@ -534,6 +537,28 @@ Options: --top-k N Retrieval count (default: 20) ``` +**List Command:** +```bash +leann list + +# Lists all indexes across all projects with status indicators: +# āœ“ - Index is complete and ready to use +# āœ— - Index is incomplete or corrupted +``` + +**Remove Command:** +```bash +leann remove INDEX_NAME [OPTIONS] + +Options: + --force, -f Force removal without confirmation + +# Smart removal: automatically finds and safely removes indexes +# - Shows all matching indexes across projects +# - Requires confirmation for cross-project removal +# - Interactive selection when multiple matches found +``` +
## šŸ—ļø Architecture & How It Works diff --git a/packages/leann-core/src/leann/cli.py b/packages/leann-core/src/leann/cli.py index 96787fa..7968926 100644 --- a/packages/leann-core/src/leann/cli.py +++ b/packages/leann-core/src/leann/cli.py @@ -84,6 +84,7 @@ Examples: leann search my-docs "query" # Search in my-docs index leann ask my-docs "question" # Ask my-docs index leann list # List all stored indexes + leann remove my-docs # Remove an index (local first, then global) """, ) @@ -251,6 +252,13 @@ Examples: # List command subparsers.add_parser("list", help="List all indexes") + # Remove command + remove_parser = subparsers.add_parser("remove", help="Remove an index") + remove_parser.add_argument("index_name", help="Index name to remove") + remove_parser.add_argument( + "--force", "-f", action="store_true", help="Force removal without confirmation" + ) + return parser def register_project_dir(self): @@ -339,8 +347,6 @@ Examples: return False def list_indexes(self): - print("Stored LEANN indexes:") - # Get all project directories with .leann global_registry = Path.home() / ".leann" / "projects.json" all_projects = [] @@ -366,55 +372,287 @@ Examples: if (current_path / ".leann" / "indexes").exists() and current_path not in valid_projects: valid_projects.append(current_path) - if not valid_projects: - print( - "No indexes found. Use 'leann build --docs [ ...]' to create one." - ) - return - - total_indexes = 0 - current_dir = Path.cwd() + # Separate current and other projects + current_project = None + other_projects = [] for project_path in valid_projects: - indexes_dir = project_path / ".leann" / "indexes" - if not indexes_dir.exists(): - continue - - index_dirs = [d for d in indexes_dir.iterdir() if d.is_dir()] - if not index_dirs: - continue - - # Show project header - if project_path == current_dir: - print(f"\nšŸ“ Current project ({project_path}):") + if project_path == current_path: + current_project = project_path else: - print(f"\nšŸ“‚ {project_path}:") + other_projects.append(project_path) - for index_dir in index_dirs: - total_indexes += 1 - index_name = index_dir.name - meta_file = index_dir / "documents.leann.meta.json" - status = "āœ“" if meta_file.exists() else "āœ—" + print("šŸ“š LEANN Indexes") + print("=" * 50) - print(f" {total_indexes}. {index_name} [{status}]") - if status == "āœ“": - size_mb = sum(f.stat().st_size for f in index_dir.iterdir() if f.is_file()) / ( - 1024 * 1024 - ) - print(f" Size: {size_mb:.1f} MB") + total_indexes = 0 + current_indexes_count = 0 - if total_indexes > 0: - print(f"\nTotal: {total_indexes} indexes across {len(valid_projects)} projects") - print("\nUsage (current project only):") - - # Show example from current project - current_indexes_dir = current_dir / ".leann" / "indexes" + # Show current project first (most important) + if current_project: + current_indexes_dir = current_project / ".leann" / "indexes" if current_indexes_dir.exists(): current_index_dirs = [d for d in current_indexes_dir.iterdir() if d.is_dir()] + + print("\nšŸ  Current Project") + print(f" {current_project}") + print(" " + "─" * 45) + if current_index_dirs: - example_name = current_index_dirs[0].name - print(f' leann search {example_name} "your query"') - print(f" leann ask {example_name} --interactive") + for index_dir in current_index_dirs: + total_indexes += 1 + current_indexes_count += 1 + index_name = index_dir.name + meta_file = index_dir / "documents.leann.meta.json" + status = "āœ…" if meta_file.exists() else "āŒ" + + print(f" {current_indexes_count}. {index_name} {status}") + if meta_file.exists(): + size_mb = sum( + f.stat().st_size for f in index_dir.iterdir() if f.is_file() + ) / (1024 * 1024) + print(f" šŸ“¦ Size: {size_mb:.1f} MB") + else: + print(" šŸ“­ No indexes in current project") + else: + print("\nšŸ  Current Project") + print(f" {current_path}") + print(" " + "─" * 45) + print(" šŸ“­ No indexes in current project") + + # Show other projects (reference information) + if other_projects: + print("\n\nšŸ—‚ļø Other Projects") + print(" " + "─" * 45) + + for project_path in other_projects: + indexes_dir = project_path / ".leann" / "indexes" + if not indexes_dir.exists(): + continue + + index_dirs = [d for d in indexes_dir.iterdir() if d.is_dir()] + if not index_dirs: + continue + + print(f"\n šŸ“‚ {project_path.name}") + print(f" {project_path}") + + for index_dir in index_dirs: + total_indexes += 1 + index_name = index_dir.name + meta_file = index_dir / "documents.leann.meta.json" + status = "āœ…" if meta_file.exists() else "āŒ" + + print(f" • {index_name} {status}") + if meta_file.exists(): + size_mb = sum( + f.stat().st_size for f in index_dir.iterdir() if f.is_file() + ) / (1024 * 1024) + print(f" šŸ“¦ {size_mb:.1f} MB") + + # Summary and usage info + print("\n" + "=" * 50) + if total_indexes == 0: + print("šŸ’” Get started:") + print(" leann build my-docs --docs ./documents") + else: + projects_count = len( + [ + p + for p in valid_projects + if (p / ".leann" / "indexes").exists() + and list((p / ".leann" / "indexes").iterdir()) + ] + ) + print(f"šŸ“Š Total: {total_indexes} indexes across {projects_count} projects") + + if current_indexes_count > 0: + print("\nšŸ’« Quick start (current project):") + # Get first index from current project for example + current_indexes_dir = current_path / ".leann" / "indexes" + if current_indexes_dir.exists(): + current_index_dirs = [d for d in current_indexes_dir.iterdir() if d.is_dir()] + if current_index_dirs: + example_name = current_index_dirs[0].name + print(f' leann search {example_name} "your query"') + print(f" leann ask {example_name} --interactive") + else: + print("\nšŸ’” Create your first index:") + print(" leann build my-docs --docs ./documents") + + def remove_index(self, index_name: str, force: bool = False): + """Safely remove an index - always show all matches for transparency""" + + # Always do a comprehensive search for safety + print(f"šŸ” Searching for all indexes named '{index_name}'...") + all_matches = self._find_all_matching_indexes(index_name) + + if not all_matches: + print(f"āŒ Index '{index_name}' not found in any project.") + return False + + if len(all_matches) == 1: + return self._remove_single_match(all_matches[0], index_name, force) + else: + return self._remove_from_multiple_matches(all_matches, index_name, force) + + def _find_all_matching_indexes(self, index_name: str): + """Find all indexes with the given name across all projects""" + matches = [] + + # Get all registered projects + global_registry = Path.home() / ".leann" / "projects.json" + all_projects = [] + + if global_registry.exists(): + try: + import json + + with open(global_registry) as f: + all_projects = json.load(f) + except Exception: + pass + + # Always include current project + current_path = Path.cwd() + if str(current_path) not in all_projects: + all_projects.append(str(current_path)) + + # Search across all projects + for project_dir in all_projects: + project_path = Path(project_dir) + if not project_path.exists(): + continue + + index_dir = project_path / ".leann" / "indexes" / index_name + if index_dir.exists(): + is_current = project_path == current_path + matches.append( + {"project_path": project_path, "index_dir": index_dir, "is_current": is_current} + ) + + # Sort: current project first, then by project name + matches.sort(key=lambda x: (not x["is_current"], x["project_path"].name)) + return matches + + def _remove_single_match(self, match, index_name: str, force: bool): + """Handle removal when only one match is found""" + project_path = match["project_path"] + index_dir = match["index_dir"] + is_current = match["is_current"] + + if is_current: + location_info = "current project" + emoji = "šŸ " + else: + location_info = f"other project '{project_path.name}'" + emoji = "šŸ“‚" + + print(f"āœ… Found 1 index named '{index_name}':") + print(f" {emoji} Location: {location_info}") + print(f" šŸ“ Path: {project_path}") + + if not force: + if not is_current: + print("\nāš ļø CROSS-PROJECT REMOVAL!") + print(" This will delete the index from another project.") + + response = input(f" ā“ Confirm removal from {location_info}? (y/N): ").strip().lower() + if response not in ["y", "yes"]: + print(" āŒ Removal cancelled.") + return False + + return self._delete_index_directory( + index_dir, index_name, project_path if not is_current else None + ) + + def _remove_from_multiple_matches(self, matches, index_name: str, force: bool): + """Handle removal when multiple matches are found""" + + print(f"āš ļø Found {len(matches)} indexes named '{index_name}':") + print(" " + "─" * 50) + + for i, match in enumerate(matches, 1): + project_path = match["project_path"] + is_current = match["is_current"] + + if is_current: + print(f" {i}. šŸ  Current project") + print(f" šŸ“ {project_path}") + else: + print(f" {i}. šŸ“‚ {project_path.name}") + print(f" šŸ“ {project_path}") + + # Show size info + try: + size_mb = sum( + f.stat().st_size for f in match["index_dir"].iterdir() if f.is_file() + ) / (1024 * 1024) + print(f" šŸ“¦ Size: {size_mb:.1f} MB") + except (OSError, PermissionError): + pass + + print(" " + "─" * 50) + + if force: + print(" āŒ Multiple matches found, but --force specified.") + print(" Please run without --force to choose which one to remove.") + return False + + try: + choice = input( + f" ā“ Which one to remove? (1-{len(matches)}, or 'c' to cancel): " + ).strip() + if choice.lower() == "c": + print(" āŒ Removal cancelled.") + return False + + choice_idx = int(choice) - 1 + if 0 <= choice_idx < len(matches): + selected_match = matches[choice_idx] + project_path = selected_match["project_path"] + index_dir = selected_match["index_dir"] + is_current = selected_match["is_current"] + + location = "current project" if is_current else f"'{project_path.name}' project" + print(f" šŸŽÆ Selected: Remove from {location}") + + # Final confirmation for safety + confirm = input( + f" ā“ FINAL CONFIRMATION - Type '{index_name}' to proceed: " + ).strip() + if confirm != index_name: + print(" āŒ Confirmation failed. Removal cancelled.") + return False + + return self._delete_index_directory( + index_dir, index_name, project_path if not is_current else None + ) + else: + print(" āŒ Invalid choice. Removal cancelled.") + return False + + except (ValueError, KeyboardInterrupt): + print("\n āŒ Invalid input. Removal cancelled.") + return False + + def _delete_index_directory( + self, index_dir: Path, index_name: str, project_path: Path | None = None + ): + """Actually delete the index directory""" + try: + import shutil + + shutil.rmtree(index_dir) + + if project_path: + print(f"āœ… Index '{index_name}' removed from {project_path.name}") + else: + print(f"āœ… Index '{index_name}' removed successfully") + return True + except Exception as e: + print(f"āŒ Error removing index '{index_name}': {e}") + return False def load_documents( self, @@ -942,6 +1180,8 @@ Examples: if args.command == "list": self.list_indexes() + elif args.command == "remove": + self.remove_index(args.index_name, args.force) elif args.command == "build": await self.build_index(args) elif args.command == "search": @@ -953,10 +1193,15 @@ Examples: def main(): + import logging + import dotenv dotenv.load_dotenv() + # Set clean logging for CLI usage + logging.getLogger().setLevel(logging.WARNING) # Only show warnings and errors + cli = LeannCLI() asyncio.run(cli.run()) diff --git a/packages/leann-core/src/leann/registry.py b/packages/leann-core/src/leann/registry.py index 054ef23..dc6df68 100644 --- a/packages/leann-core/src/leann/registry.py +++ b/packages/leann-core/src/leann/registry.py @@ -2,11 +2,15 @@ import importlib import importlib.metadata +import logging from typing import TYPE_CHECKING if TYPE_CHECKING: from leann.interface import LeannBackendFactoryInterface +# Set up logger for this module +logger = logging.getLogger(__name__) + BACKEND_REGISTRY: dict[str, "LeannBackendFactoryInterface"] = {} @@ -14,7 +18,7 @@ def register_backend(name: str): """A decorator to register a new backend class.""" def decorator(cls): - print(f"INFO: Registering backend '{name}'") + logger.debug(f"Registering backend '{name}'") BACKEND_REGISTRY[name] = cls return cls