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