claudix 0.1.1

Local semantic search plugin for Claude Code
Documentation
use std::env;
use std::io::{self, Read};
use std::panic;

use anyhow::Result;
use clap::{Parser, Subcommand};
use claudix::{ClaudixError, cli, hooks, mcp};
use serde_json::to_string;

#[derive(Debug, Parser)]
#[command(name = "claudix")]
#[command(about = "Local semantic search for Claude Code")]
#[command(version)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Debug, Subcommand)]
enum Command {
    #[command(about = "Index or re-index the current repository")]
    Index {
        #[arg(long, help = "Clear the index before re-indexing")]
        force: bool,
        #[arg(long, help = "Show live indexing progress")]
        progress: bool,
    },
    #[command(about = "Search indexed code semantically")]
    Search {
        #[arg(num_args = 1.., help = "Natural-language or identifier query (multi-word, no quoting needed)")]
        query: Vec<String>,
        #[arg(long, help = "Maximum results to return (default: from config)")]
        top_k: Option<usize>,
        #[arg(
            long = "language",
            help = "Filter by language (rust, python, go, …); repeatable"
        )]
        language_filter: Vec<String>,
        #[arg(long, help = "Restrict results to paths starting with this prefix")]
        path_prefix: Option<String>,
    },
    #[command(about = "Show index status (chunk count, model, last indexed)")]
    Status,
    #[command(about = "Re-embed a single file after editing")]
    ReindexFile {
        #[arg(help = "Path to the file, relative or absolute inside the project")]
        path: String,
    },
    #[command(about = "Watch saved files and re-embed changed files when watch = true")]
    Watch,
    #[command(about = "Drop the entire index dataset")]
    Clear,
    #[command(about = "Handle a Claude Code hook event (SessionStart | PostToolUse | PreToolUse)")]
    Hook {
        #[arg(help = "Hook event name from Claude Code")]
        event: String,
    },
    #[command(about = "Diagnose binary, index, and embedding health")]
    Doctor,
    #[command(about = "Bootstrap plugin files and download the bundled embedding model")]
    Install,
    #[command(about = "Run as an MCP server over stdio (invoked by Claude Code)")]
    Mcp,
}

#[tokio::main]
async fn main() {
    panic::set_hook(Box::new(|panic_info| {
        eprintln!("claudix panic: {panic_info}");
    }));

    if let Err(err) = run().await {
        if let Some(hint) = err
            .downcast_ref::<ClaudixError>()
            .and_then(ClaudixError::recovery_hint)
        {
            eprintln!("error: {err}");
            eprintln!("hint: {hint}");
        } else {
            eprintln!("error: {err}");
        }
        std::process::exit(1);
    }
}

async fn run() -> Result<()> {
    let cli = Cli::parse();
    let project_root = active_project_root()?;

    match cli.command {
        Command::Index { force, progress } => {
            if force {
                cli::run_clear_index(&project_root).await?;
            }
            let output = if progress {
                cli::run_index_with_progress(&project_root).await?
            } else {
                cli::run_index(&project_root).await?
            };
            println!(
                "indexed {} files into {} chunks",
                output.file_count, output.chunk_count
            );
        }
        Command::Search {
            query,
            top_k,
            language_filter,
            path_prefix,
        } => {
            let output = cli::run_search(
                &project_root,
                query.join(" "),
                top_k,
                if language_filter.is_empty() {
                    None
                } else {
                    Some(language_filter)
                },
                path_prefix,
            )
            .await?;

            for hit in output.hits {
                let stale_warning = stale_warning(hit.stale);
                match hit.name {
                    Some(name) => println!(
                        "{}:{}-{} [{}] {} {} {}{}",
                        hit.file_path,
                        hit.line_start,
                        hit.line_end,
                        hit.language,
                        hit.kind,
                        name,
                        hit.score,
                        stale_warning
                    ),
                    None => println!(
                        "{}:{}-{} [{}] {} {}{}",
                        hit.file_path,
                        hit.line_start,
                        hit.line_end,
                        hit.language,
                        hit.kind,
                        hit.score,
                        stale_warning
                    ),
                }
            }
        }
        Command::Status => {
            let output = cli::run_status(&project_root).await?;
            print_index_stats(
                output.chunk_count,
                output.file_count,
                output.model.as_deref(),
                output.dimensions,
            );
            if let Some(last_full_index_at) = output.last_full_index_at {
                println!("last_full_index_at: {last_full_index_at}");
            }
            if let Some(last_incremental_at) = output.last_incremental_at {
                println!("last_incremental_at: {last_incremental_at}");
            }
            println!("stale: {}", output.stale);
        }
        Command::ReindexFile { path } => {
            let output = cli::run_reindex_file(&project_root, path).await?;
            println!(
                "indexed {} files into {} chunks",
                output.file_count, output.chunk_count
            );
        }
        Command::Watch => {
            cli::run_watch(&project_root).await?;
        }
        Command::Clear => {
            let output = cli::run_clear_index(&project_root).await?;
            println!("cleared: {}", output.cleared);
        }
        Command::Hook { event } => match cli::parse_hook_event(&event) {
            Ok(event) => run_hook_command(&project_root, event).await,
            Err(_) => {
                eprintln!("claudix: unknown hook event '{event}', ignoring (fail-open)");
            }
        },
        Command::Doctor => {
            let output = cli::run_doctor(&project_root).await?;
            println!("project_root: {}", output.project_root);
            println!("index_present: {}", output.index_present);
            print_index_stats(
                output.chunk_count,
                output.file_count,
                output.model.as_deref(),
                output.dimensions,
            );
            println!("embedding_provider: {}", output.embedding_provider);
            println!("embedding_healthy: {}", output.embedding_healthy);
            if output.embedding_model_mismatch {
                println!("embedding_model_mismatch: true");
            }

            if output.embedding_model_mismatch {
                eprintln!(
                    "\nembedding model mismatch — the index was built with a different model.\n\
                     Fix: run `claudix clear && claudix index` to rebuild with the active model."
                );
            } else if !output.embedding_healthy {
                eprintln!(
                    "\nembedding server not reachable — start LM Studio / Ollama,\n\
                     or run `claudix install` to switch to the bundled model."
                );
            }
            if !output.index_present {
                eprintln!("\nindex not built — run `claudix index` to index the repository.");
            }
        }
        Command::Install => {
            let output = cli::run_install(&project_root).await?;
            println!("plugin config: {}", output.config_path);
        }
        Command::Mcp => {
            mcp::run(&project_root).await?;
        }
    }

    Ok(())
}

fn stale_warning(stale: bool) -> &'static str {
    if stale {
        " [STALE - file modified since index]"
    } else {
        ""
    }
}

fn print_index_stats(
    chunk_count: usize,
    file_count: usize,
    model: Option<&str>,
    dimensions: Option<u16>,
) {
    println!("chunks: {chunk_count}");
    println!("files: {file_count}");
    if let Some(model) = model {
        println!("model: {model}");
    }
    if let Some(dimensions) = dimensions {
        println!("dimensions: {dimensions}");
    }
}

fn active_project_root() -> Result<std::path::PathBuf> {
    match env::var_os("CLAUDE_PROJECT_DIR") {
        Some(path) => Ok(path.into()),
        None => Ok(env::current_dir()?),
    }
}

async fn run_hook_command(project_root: &std::path::Path, event: hooks::HookEvent) {
    let payload = read_stdin_payload();
    let project_root = project_root.to_path_buf();
    let handle = tokio::spawn(async move { hooks::run(&project_root, event, &payload).await });

    match handle.await {
        Ok(Ok(Some(response))) => {
            if let Ok(encoded) = to_string(&response) {
                println!("{encoded}");
            }
        }
        Ok(Ok(None)) => {}
        Ok(Err(error)) => {
            eprintln!("claudix hook failed open: {error}");
        }
        Err(_) => {
            eprintln!("claudix hook panicked and failed open");
        }
    }
}

fn read_stdin_payload() -> String {
    let mut payload = String::new();
    if io::stdin().read_to_string(&mut payload).is_ok() {
        payload
    } else {
        String::new()
    }
}