import asyncio import json import warnings from importlib.metadata import version from pathlib import Path from typing import Any import typer from haiku.rag.config import Config from haiku.rag.logging import configure_cli_logging from haiku.rag.utils import is_up_to_date cli = typer.Typer( context_settings={"help_option_names": ["-h", "--help"]}, no_args_is_help=True ) async def check_version(): """Check if haiku.rag is up to date and show warning if not.""" up_to_date, current_version, latest_version = await is_up_to_date() if not up_to_date: typer.echo( f"Warning: haiku.rag is outdated. Current: {current_version}, Latest: {latest_version}", ) typer.echo("Please update.") def version_callback(value: bool): if value: v = version("haiku.rag") typer.echo(f"haiku.rag version {v}") raise typer.Exit() @cli.callback() def main( _version: bool = typer.Option( False, "-v", "--version", callback=version_callback, help="Show version and exit", ), config: Path | None = typer.Option( None, "--config", help="Path to YAML configuration file", ), ): """haiku.rag CLI - Vector database RAG system""" # Store config path in environment for config loader to use if config: import os os.environ["HAIKU_RAG_CONFIG_PATH"] = str(config.absolute()) # Configure logging minimally for CLI context if Config.environment == "development": # Lazy import logfire only in development try: import logfire # type: ignore logfire.configure(send_to_logfire="if-token-present") logfire.instrument_pydantic_ai() except Exception: pass else: configure_cli_logging() warnings.filterwarnings("ignore") # Run version check before any command try: asyncio.run(check_version()) except Exception: # Do not block CLI on version check issues pass @cli.command("list", help="List all stored documents") def list_documents( db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) asyncio.run(app.list_documents()) def _parse_meta_options(meta: list[str] | None) -> dict[str, Any]: """Parse repeated --meta KEY=VALUE options into a dictionary. Raises a Typer error if any entry is malformed. """ result: dict[str, Any] = {} if not meta: return result for item in meta: if "=" not in item: raise typer.BadParameter("--meta must be in KEY=VALUE format") key, value = item.split("=", 1) if not key: raise typer.BadParameter("--meta key cannot be empty") # Best-effort JSON coercion: numbers, booleans, null, arrays/objects try: parsed = json.loads(value) result[key] = parsed except Exception: # Leave as string if not valid JSON literal result[key] = value return result @cli.command("add", help="Add a document from text input") def add_document_text( text: str = typer.Argument( help="The text content of the document to add", ), meta: list[str] | None = typer.Option( None, "--meta", help="Metadata entries as KEY=VALUE (repeatable)", metavar="KEY=VALUE", ), db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) metadata = _parse_meta_options(meta) asyncio.run(app.add_document_from_text(text=text, metadata=metadata or None)) @cli.command("add-src", help="Add a document from a file path, directory, or URL") def add_document_src( source: str = typer.Argument( help="The file path, directory, or URL of the document(s) to add", ), title: str | None = typer.Option( None, "--title", help="Optional human-readable title to store with the document", ), meta: list[str] | None = typer.Option( None, "--meta", help="Metadata entries as KEY=VALUE (repeatable)", metavar="KEY=VALUE", ), db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) metadata = _parse_meta_options(meta) asyncio.run( app.add_document_from_source( source=source, title=title, metadata=metadata or None ) ) @cli.command("get", help="Get and display a document by its ID") def get_document( doc_id: str = typer.Argument( help="The ID of the document to get", ), db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) asyncio.run(app.get_document(doc_id=doc_id)) @cli.command("delete", help="Delete a document by its ID") def delete_document( doc_id: str = typer.Argument( help="The ID of the document to delete", ), db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) asyncio.run(app.delete_document(doc_id=doc_id)) # Add alias `rm` for delete cli.command("rm", help="Alias for delete: remove a document by its ID")(delete_document) @cli.command("search", help="Search for documents by a query") def search( query: str = typer.Argument( help="The search query to use", ), limit: int = typer.Option( 5, "--limit", "-l", help="Maximum number of results to return", ), filter: str | None = typer.Option( None, "--filter", "-f", help="SQL WHERE clause to filter documents (e.g., \"uri LIKE '%arxiv%'\")", ), db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) asyncio.run(app.search(query=query, limit=limit, filter=filter)) @cli.command("ask", help="Ask a question using the QA agent") def ask( question: str = typer.Argument( help="The question to ask", ), db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), cite: bool = typer.Option( False, "--cite", help="Include citations in the response", ), deep: bool = typer.Option( False, "--deep", help="Use deep multi-agent QA for complex questions", ), verbose: bool = typer.Option( False, "--verbose", help="Show verbose progress output (only with --deep)", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) asyncio.run(app.ask(question=question, cite=cite, deep=deep, verbose=verbose)) @cli.command("research", help="Run multi-agent research and output a concise report") def research( question: str = typer.Argument( help="The research question to investigate", ), max_iterations: int = typer.Option( 3, "--max-iterations", "-n", help="Maximum search/analyze iterations", ), confidence_threshold: float = typer.Option( 0.8, "--confidence-threshold", help="Minimum confidence (0-1) to stop", ), max_concurrency: int = typer.Option( 1, "--max-concurrency", help="Max concurrent searches per iteration (planned)", ), db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), verbose: bool = typer.Option( False, "--verbose", help="Show verbose progress output", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) asyncio.run( app.research( question=question, max_iterations=max_iterations, confidence_threshold=confidence_threshold, max_concurrency=max_concurrency, verbose=verbose, ) ) @cli.command("settings", help="Display current configuration settings") def settings(): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=Path()) # Don't need actual DB for settings app.show_settings() @cli.command("init-config", help="Generate a YAML configuration file") def init_config( output: Path = typer.Argument( Path("haiku.rag.yaml"), help="Output path for the config file", ), from_env: bool = typer.Option( False, "--from-env", help="Migrate settings from .env file", ), ): """Generate a YAML configuration file with defaults or from .env.""" import yaml from haiku.rag.config.loader import generate_default_config, load_config_from_env if output.exists(): typer.echo( f"Error: {output} already exists. Remove it first or choose a different path." ) raise typer.Exit(1) if from_env: # Load from environment variables (including .env if present) from dotenv import load_dotenv load_dotenv() config_data = load_config_from_env() if not config_data: typer.echo("Warning: No environment variables found to migrate.") typer.echo("Generating default configuration instead.") config_data = generate_default_config() else: config_data = generate_default_config() # Write YAML with comments with open(output, "w") as f: f.write("# haiku.rag configuration file\n") f.write( "# See https://ggozad.github.io/haiku.rag/configuration/ for details\n\n" ) yaml.dump(config_data, f, default_flow_style=False, sort_keys=False) typer.echo(f"Configuration file created: {output}") typer.echo("Edit the file to customize your settings.") @cli.command( "rebuild", help="Rebuild the database by deleting all chunks and re-indexing all documents", ) def rebuild( db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) asyncio.run(app.rebuild()) @cli.command("vacuum", help="Optimize and clean up all tables to reduce disk usage") def vacuum( db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) asyncio.run(app.vacuum()) @cli.command("info", help="Show read-only database info (no upgrades or writes)") def info( db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), ): from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) asyncio.run(app.info()) @cli.command("download-models", help="Download Docling and Ollama models per config") def download_models_cmd(): from haiku.rag.utils import prefetch_models try: prefetch_models() typer.echo("Models downloaded successfully.") except Exception as e: typer.echo(f"Error downloading models: {e}") raise typer.Exit(1) @cli.command( "serve", help="Start haiku.rag server. Use --monitor, --mcp, and/or --a2a to enable services.", ) def serve( db: Path = typer.Option( Config.storage.data_dir / "haiku.rag.lancedb", "--db", help="Path to the LanceDB database file", ), monitor: bool = typer.Option( False, "--monitor", help="Enable file monitoring", ), mcp: bool = typer.Option( False, "--mcp", help="Enable MCP server", ), stdio: bool = typer.Option( False, "--stdio", help="Run MCP server on stdio Transport (requires --mcp)", ), mcp_port: int = typer.Option( 8001, "--mcp-port", help="Port to bind MCP server to (ignored with --stdio)", ), a2a: bool = typer.Option( False, "--a2a", help="Enable A2A (Agent-to-Agent) server", ), a2a_host: str = typer.Option( "127.0.0.1", "--a2a-host", help="Host to bind A2A server to", ), a2a_port: int = typer.Option( 8000, "--a2a-port", help="Port to bind A2A server to", ), ) -> None: """Start the server with selected services.""" # Require at least one service flag if not (monitor or mcp or a2a): typer.echo( "Error: At least one service flag (--monitor, --mcp, or --a2a) must be specified" ) raise typer.Exit(1) if stdio and not mcp: typer.echo("Error: --stdio requires --mcp") raise typer.Exit(1) from haiku.rag.app import HaikuRAGApp app = HaikuRAGApp(db_path=db) transport = "stdio" if stdio else None asyncio.run( app.serve( enable_monitor=monitor, enable_mcp=mcp, mcp_transport=transport, mcp_port=mcp_port, enable_a2a=a2a, a2a_host=a2a_host, a2a_port=a2a_port, ) ) @cli.command( "a2aclient", help="Run interactive client to chat with haiku.rag's A2A server" ) def a2aclient( url: str = typer.Option( "http://localhost:8000", "--url", help="Base URL of the A2A server", ), ): try: from haiku.rag.a2a.client import run_interactive_client except ImportError: typer.echo( "Error: A2A support requires the 'a2a' extra. " "Install with: uv pip install 'haiku.rag[a2a]'" ) raise typer.Exit(1) asyncio.run(run_interactive_client(url=url)) if __name__ == "__main__": cli()