use crate::cli::leindex::LeIndex;
use crate::cli::mcp::McpServer;
use crate::cli::mcp::output::render_tool_output;
#[cfg(test)]
use mcp_commands::find_tool_handler;
use mcp_commands::{
cmd_mcp_socket_impl, cmd_mcp_stdio_impl, cmd_tools_impl, execute_tool_handler, merge_tool_args,
};
#[path = "mcp_commands.rs"]
mod mcp_commands;
#[path = "phase_commands.rs"]
mod phase_commands;
use anyhow::Context;
use anyhow::Result as AnyhowResult;
use clap::{Parser, Subcommand, error::ErrorKind};
use phase_commands::{PhaseCommandArgs, cmd_phase_impl};
use serde_json::Value;
use std::fs;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use tracing::{info, warn};
mod storage_cleanup;
use storage_cleanup::*;
#[path = "cli/services_commands.rs"]
mod services_commands;
use services_commands::cmd_services_impl;
const POST_INSTALL_SKIP_ENV: &str = "LEINDEX_SKIP_POST_INSTALL_HOOK";
const POST_INSTALL_STAR_MARKER: &str = ".github-starred";
const POST_INSTALL_VERSION_MARKER: &str = ".post-install-version";
const REPO_STAR_ENDPOINT: &str = "user/starred/scooter-lacroix/LeIndex";
#[derive(Parser, Debug)]
#[command(name = "leindex")]
#[command(author = "LeIndex Contributors")]
#[command(version = env!("CARGO_PKG_VERSION"))]
#[command(about = "Index, search, and analyze codebases with semantic understanding", long_about = None)]
#[command(subcommand_required = false)]
#[command(arg_required_else_help = false)]
pub struct Cli {
#[arg(global = true, long = "project", short = 'p')]
pub project_path: Option<PathBuf>,
#[arg(global = true, long = "verbose", short = 'v')]
pub verbose: bool,
#[arg(long = "stdio")]
pub stdio: bool,
#[arg(global = true, long = "memory-report", value_name = "PATH")]
pub memory_report: Option<PathBuf>,
#[command(subcommand)]
pub command: Option<Commands>,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
#[command(visible_alias = "leindex_index")]
Index {
#[arg(value_name = "PATH")]
path: PathBuf,
#[arg(long = "force")]
force: bool,
#[arg(long = "progress")]
progress: bool,
#[arg(long = "max-memory", value_name = "MB")]
max_memory: Option<u64>,
},
#[command(visible_alias = "leindex_search")]
Search {
#[arg(value_name = "QUERY")]
query: String,
#[arg(long = "top-k", default_value = "10")]
top_k: usize,
#[arg(
long = "rerank",
value_name = "BACKEND",
value_parser = ["onnx", "system-one", "off"]
)]
rerank: Option<String>,
},
#[command(visible_alias = "leindex_deep_analyze")]
Analyze {
#[arg(value_name = "QUERY")]
query: String,
#[arg(long = "tokens", default_value = "2000")]
token_budget: usize,
},
#[command(visible_alias = "leindex_context")]
Context {
#[arg(value_name = "NODE_ID")]
node_id: String,
#[arg(long = "tokens", default_value = "2000")]
token_budget: usize,
},
#[command(visible_aliases = ["leindex_phase_analysis", "phase_analysis"])]
Phase {
#[arg(long = "phase")]
phase: Option<u8>,
#[arg(long = "all", default_value_t = false)]
all: bool,
#[arg(long = "mode", default_value = "balanced")]
mode: String,
#[arg(long = "path")]
path: Option<PathBuf>,
#[arg(long = "max-files", default_value = "2000")]
max_files: usize,
#[arg(long = "max-focus-files", default_value = "20")]
max_focus_files: usize,
#[arg(long = "top-n", default_value = "10")]
top_n: usize,
#[arg(long = "max-chars", default_value = "12000")]
max_output_chars: usize,
#[arg(long = "include-docs", default_value_t = false)]
include_docs: bool,
#[arg(long = "docs-mode", default_value = "off")]
docs_mode: String,
#[arg(long = "no-incremental-refresh", default_value_t = false)]
no_incremental_refresh: bool,
},
#[command(visible_alias = "leindex_diagnostics")]
Diagnostics,
#[command(disable_help_subcommand = true)]
Tools {
#[command(subcommand)]
command: ToolCommands,
},
Serve {
#[arg(long = "host", default_value = "127.0.0.1")]
host: String,
#[arg(long = "port", default_value = "47500")]
port: u16,
},
Mcp {
#[arg(long = "stdio")]
stdio: bool,
#[arg(long = "socket")]
socket: Option<PathBuf>,
#[arg(long = "mcp-idle-timeout-secs")]
idle_timeout_secs: Option<u64>,
},
Dashboard {
#[arg(long = "port", default_value = "5173")]
port: u16,
#[arg(long = "prod")]
prod: bool,
},
Cleanup {
#[arg(long = "max-age-days", default_value = "7")]
max_age_days: u64,
#[arg(long = "dry-run")]
dry_run: bool,
#[arg(long = "stale-daemons")]
stale_daemons: bool,
#[arg(long = "store")]
store: bool,
},
#[command(visible_alias = "leindex_retention")]
Retention {
#[arg(long = "report")]
report: bool,
#[arg(long = "gc")]
gc: bool,
#[arg(long = "max-generations", default_value_t = 3)]
max_generations: usize,
#[arg(long = "dry-run")]
dry_run: bool,
},
#[command(visible_alias = "leindex_storage_migrate")]
Storage {
#[arg(long = "migrate")]
migrate: bool,
#[arg(long = "status")]
status: bool,
#[arg(long = "job-bytes-max", value_name = "BYTES")]
job_bytes_max: Option<u64>,
#[arg(long = "footprint-mib", value_name = "MIB")]
footprint_mib: Option<u64>,
},
Setup {
#[arg(long = "neural")]
neural: bool,
#[arg(long = "no-neural", conflicts_with = "neural")]
no_neural: bool,
#[arg(long = "cpu", conflicts_with = "gpu")]
cpu: bool,
#[arg(long = "gpu", value_name = "amd|nvidia", conflicts_with = "cpu")]
gpu: Option<String>,
#[arg(long = "check")]
check: bool,
#[arg(long = "warmup")]
warmup: bool,
#[arg(long = "remote-gemma")]
remote_gemma: bool,
#[command(flatten)]
options: crate::cli::leindex::setup::SetupOptions,
},
Services {
#[command(subcommand)]
action: Option<ServiceAction>,
},
Config {
#[command(subcommand)]
action: crate::cli::leindex::setup_config::ConfigAction,
},
}
#[derive(Subcommand, Debug)]
pub enum ServiceAction {
List,
Status {
name: String,
},
Start {
name: String,
},
Stop {
name: String,
},
Restart {
name: String,
},
}
#[derive(Subcommand, Debug)]
pub enum ToolCommands {
List {
#[arg(long)]
verbose: bool,
},
#[command(alias = "help")]
Inspect {
name: String,
},
Schema {
name: String,
},
Run {
name: String,
#[arg(long = "args", default_value = "{}")]
args_json: String,
#[arg(long = "set", value_name = "KEY=VALUE")]
set: Vec<String>,
},
}
impl Cli {
pub async fn run(self) -> AnyhowResult<()> {
init_logging_impl(self.verbose);
crate::cli::cleanup::startup_gc();
if let Some(path) =
crate::cli::memory_report::resolve_report_path(self.memory_report.as_deref())
{
crate::cli::memory_report::init_tracker(
crate::cli::memory_report::MemoryReportTracker::new(path),
);
}
let global_project = self.project_path;
let command = if self.stdio {
Commands::Mcp {
stdio: true,
socket: None,
idle_timeout_secs: None,
}
} else {
self.command.unwrap_or(Commands::Mcp {
stdio: false,
socket: None,
idle_timeout_secs: None,
})
};
maybe_complete_post_install_actions(&command);
let result = match command {
Commands::Index {
path,
force,
progress,
max_memory,
} => cmd_index_impl(path, force, progress, max_memory).await,
Commands::Search {
query,
top_k,
rerank,
} => cmd_search_impl(query, top_k, rerank, global_project).await,
Commands::Analyze {
query,
token_budget,
} => cmd_analyze_impl(query, token_budget, global_project).await,
Commands::Context {
node_id,
token_budget,
} => cmd_context_impl(node_id, token_budget, global_project).await,
Commands::Phase {
phase,
all,
mode,
path,
max_files,
max_focus_files,
top_n,
max_output_chars,
include_docs,
docs_mode,
no_incremental_refresh,
} => {
cmd_phase_impl(PhaseCommandArgs {
phase,
all,
mode,
path,
project: global_project,
max_files,
max_focus_files,
top_n,
max_output_chars,
include_docs,
docs_mode,
no_incremental_refresh,
})
.await
}
Commands::Diagnostics => cmd_diagnostics_impl(global_project).await,
Commands::Tools { command } => cmd_tools_impl(command, global_project).await,
Commands::Serve { host, port } => cmd_serve_impl(host, port).await,
Commands::Mcp {
socket,
idle_timeout_secs,
..
} => {
if let Some(ref socket_path) = socket {
cmd_mcp_socket_impl(socket_path, global_project, idle_timeout_secs).await
} else {
cmd_mcp_stdio_impl(global_project, idle_timeout_secs).await
}
}
Commands::Dashboard { port, prod } => cmd_dashboard_impl(port, prod).await,
Commands::Cleanup {
max_age_days,
dry_run,
stale_daemons,
store,
} => {
cmd_cleanup_impl(max_age_days, dry_run, stale_daemons, store, global_project).await
}
Commands::Retention {
report,
gc,
max_generations,
dry_run,
} => cmd_retention_impl(report, gc, max_generations, dry_run, global_project).await,
Commands::Storage {
migrate,
status,
job_bytes_max,
footprint_mib,
} => {
if status {
cmd_storage_status_impl(global_project).await?;
} else if migrate {
cmd_storage_migrate_impl(global_project, job_bytes_max, footprint_mib).await?;
} else {
println!(
"LeIndex Storage\n\n\
Use `leindex storage --status` to inspect the generation-store\n\
layout (legacy vs. migrated) and `leindex storage --migrate` to\n\
run the one-time legacy→CAS migration sweep. The sweep is\n\
destructive: back up `.leindex/` first."
);
}
Ok(())
}
Commands::Setup {
neural,
no_neural,
cpu,
gpu,
check,
warmup,
remote_gemma,
options,
} => {
cmd_setup_impl(
neural,
no_neural,
cpu,
gpu,
check,
warmup,
remote_gemma,
options,
)
.await
}
Commands::Services { action } => cmd_services_impl(action).await,
Commands::Config { action } => crate::cli::leindex::setup_config::run(action),
};
crate::cli::memory_report::shutdown();
crate::cli::cleanup::flush_registered_temp_cleanups();
result
}
}
fn init_logging_impl(verbose: bool) {
let filter = if verbose {
tracing_subscriber::EnvFilter::new("debug")
} else {
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"))
};
let subscriber = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.finish();
let _ = tracing::subscriber::set_global_default(subscriber);
}
fn maybe_complete_post_install_actions(command: &Commands) {
if std::env::var_os(POST_INSTALL_SKIP_ENV).is_some()
|| matches!(command, Commands::Mcp { .. })
|| !running_from_cargo_bin()
{
return;
}
let leindex_home = match resolve_leindex_home() {
Ok(path) => path,
Err(error) => {
warn!("Post-install actions skipped: {}", error);
return;
}
};
if post_install_is_current(&leindex_home) {
return;
}
if let Err(error) = complete_post_install_actions(command, &leindex_home) {
warn!("Post-install actions skipped: {}", error);
}
}
fn complete_post_install_actions(
command: &Commands,
leindex_home: &std::path::Path,
) -> AnyhowResult<()> {
fs::create_dir_all(leindex_home).context("failed to create LEINDEX_HOME")?;
cleanup_legacy_user_installations(leindex_home);
let marker_path = leindex_home.join(POST_INSTALL_STAR_MARKER);
if !marker_path.exists() {
emit_post_install_message(command, "Thank you for installing LeIndex.");
if try_star_repo() {
emit_post_install_message(command, "Starred scooter-lacroix/LeIndex on GitHub.");
fs::write(&marker_path, b"starred\n").context("failed to persist star marker")?;
} else {
emit_post_install_message(
command,
"Could not star the GitHub repo automatically. If GitHub CLI is signed in, run: gh api -X PUT user/starred/scooter-lacroix/LeIndex",
);
fs::write(&marker_path, b"prompted\n").context("failed to persist star marker")?;
}
}
warn_if_path_is_shadowed(command);
write_post_install_version_marker(leindex_home)?;
Ok(())
}
fn resolve_leindex_home() -> AnyhowResult<PathBuf> {
if let Ok(path) = std::env::var("LEINDEX_HOME") {
return Ok(PathBuf::from(path));
}
let home = dirs::home_dir().context("HOME is not available")?;
Ok(home.join(".leindex"))
}
fn post_install_is_current(leindex_home: &std::path::Path) -> bool {
let marker_path = leindex_home.join(POST_INSTALL_VERSION_MARKER);
match fs::read_to_string(marker_path) {
Ok(version) => version.trim() == env!("CARGO_PKG_VERSION"),
Err(_) => false,
}
}
fn write_post_install_version_marker(leindex_home: &std::path::Path) -> AnyhowResult<()> {
let marker_path = leindex_home.join(POST_INSTALL_VERSION_MARKER);
fs::write(marker_path, format!("{}\n", env!("CARGO_PKG_VERSION")))
.context("failed to persist post-install marker")
}
fn cleanup_legacy_user_installations(leindex_home: &std::path::Path) {
let Some(home) = dirs::home_dir() else {
return;
};
let binary_name = platform_binary_name("leindex");
let legacy_local_bin = home.join(".local").join("bin").join(&binary_name);
if legacy_local_bin.exists() {
match fs::remove_file(&legacy_local_bin) {
Ok(_) => info!("Removed legacy install at {}", legacy_local_bin.display()),
Err(error) => warn!(
"Failed to remove legacy install at {}: {}",
legacy_local_bin.display(),
error
),
}
}
let legacy_home_bin = leindex_home.join("bin").join(binary_name);
if legacy_home_bin.exists() {
match fs::remove_file(&legacy_home_bin) {
Ok(_) => info!("Removed legacy install at {}", legacy_home_bin.display()),
Err(error) => warn!(
"Failed to remove legacy install at {}: {}",
legacy_home_bin.display(),
error
),
}
}
}
fn running_from_cargo_bin() -> bool {
let Ok(current_exe) = std::env::current_exe() else {
return false;
};
let cargo_home = cargo_home_dir();
let Some(cargo_home) = cargo_home else {
return false;
};
current_exe == cargo_home.join("bin").join(platform_binary_name("leindex"))
}
fn resolve_path_binary(binary_name: &str) -> Option<PathBuf> {
let path_var = std::env::var_os("PATH")?;
for entry in std::env::split_paths(&path_var) {
let candidate = entry.join(binary_name);
if candidate.is_file() {
return Some(candidate);
}
if cfg!(windows) {
let exe_candidate = entry.join(platform_binary_name(binary_name));
if exe_candidate.is_file() {
return Some(exe_candidate);
}
}
}
None
}
fn warn_if_path_is_shadowed(command: &Commands) {
let Ok(current_exe) = std::env::current_exe() else {
return;
};
let Some(resolved) = resolve_path_binary("leindex") else {
return;
};
if resolved == current_exe {
return;
}
emit_post_install_message(
command,
&format!(
"`leindex` currently resolves to {} instead of {}. Remove the older binary or move {} earlier in PATH.",
resolved.display(),
current_exe.display(),
cargo_bin_dir()
.unwrap_or_else(|| current_exe
.parent()
.unwrap_or_else(|| std::path::Path::new("."))
.to_path_buf())
.display()
),
);
}
fn cargo_home_dir() -> Option<PathBuf> {
std::env::var("CARGO_HOME")
.map(PathBuf::from)
.ok()
.or_else(|| dirs::home_dir().map(|home| home.join(".cargo")))
}
fn cargo_bin_dir() -> Option<PathBuf> {
cargo_home_dir().map(|cargo_home| cargo_home.join("bin"))
}
fn platform_binary_name(binary_name: &str) -> String {
if cfg!(windows) {
format!("{}.exe", binary_name)
} else {
binary_name.to_string()
}
}
fn try_star_repo() -> bool {
let auth_ok = Command::new("gh")
.args(["auth", "status"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false);
if !auth_ok {
return false;
}
Command::new("gh")
.args([
"api",
"-X",
"PUT",
"-H",
"Accept: application/vnd.github+json",
REPO_STAR_ENDPOINT,
])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
fn emit_post_install_message(command: &Commands, message: &str) {
if matches!(command, Commands::Serve { .. } | Commands::Dashboard { .. }) {
info!("{}", message);
} else {
eprintln!("{}", message);
}
}
fn get_project_path(explicit: Option<PathBuf>) -> PathBuf {
explicit.unwrap_or_else(|| std::env::current_dir().unwrap())
}
async fn cmd_index_impl(
path: PathBuf,
force: bool,
_progress: bool,
max_memory: Option<u64>,
) -> AnyhowResult<()> {
let canonical_path = path
.canonicalize()
.context("Failed to canonicalize project path")?;
info!("Indexing project at: {}", canonical_path.display());
if let Some(mb) = max_memory {
crate::cli::memory_cap::apply_hard_limit(mb)?;
}
let mut leindex = LeIndex::new(&canonical_path).context("Failed to create LeIndex instance")?;
if !force && leindex.is_indexed() && !leindex.is_stale_fast() {
println!("Project already indexed and up-to-date. Use --force to re-index.");
return Ok(());
}
let max_memory_bytes = max_memory.map(|mb| mb * 1024 * 1024);
let index_started = std::time::Instant::now();
let stats = tokio::task::spawn_blocking(move || {
let result = leindex.index_project_with_memory_cap(force, max_memory_bytes);
if std::env::var_os("LEINDEX_CLI_SHUTDOWN_DAEMON").is_some() {
leindex.shutdown_daemon();
}
result
})
.await
.context("Indexing task failed")?
.context("Indexing failed")?;
println!("\n✓ Indexing complete!");
println!(
" Wall time: {}s (core pipeline: {}ms; the remainder is neural embedding and publication)",
index_started.elapsed().as_secs_f32(),
stats.indexing_time_ms
);
println!(" Files parsed: {}", stats.files_parsed);
println!(" Successful: {}", stats.successful_parses);
println!(" Failed: {}", stats.failed_parses);
println!(
" Signatures: {}{}",
stats.total_signatures,
if stats.signature_scope == "delta" {
" (changed files only; project total unchanged since last full index)"
} else {
""
}
);
println!(" PDG nodes: {}", stats.pdg_nodes);
println!(" PDG edges: {}", stats.pdg_edges);
println!(" Indexed nodes: {}", stats.indexed_nodes);
println!(" Time: {}ms", stats.indexing_time_ms);
Ok(())
}
async fn cmd_search_impl(
query: String,
top_k: usize,
rerank: Option<String>,
project: Option<PathBuf>,
) -> AnyhowResult<()> {
let project_path = get_project_path(project);
let canonical_path = project_path
.canonicalize()
.context("Failed to canonicalize project path")?;
if let Some(backend) = &rerank {
super::leindex::set_rerank_backend_override(backend);
}
info!("Searching for: {}", query);
let mut leindex = LeIndex::new(&canonical_path).context("Failed to create LeIndex instance")?;
if let Err(e) = leindex.load_from_storage() {
warn!("Failed to load from storage: {}", e);
warn!("Project may not be indexed. Run 'leindex index' first.");
}
let results = leindex
.search(&query, top_k, None)
.context("Search failed")?;
if std::env::var_os("LEINDEX_CLI_SHUTDOWN_DAEMON").is_some() {
leindex.shutdown_daemon();
}
if results.is_empty() {
println!("No results found for: {}", query);
return Ok(());
}
let results_json: Vec<Value> = results
.iter()
.map(|r| {
serde_json::json!({
"rank": r.rank,
"symbol": r.symbol_name,
"file_path": r.file_path,
"node_id": r.node_id,
"score": r.score.overall,
"tfidf_score": r.score.tfidf,
"neural_score": r.score.neural,
"text_score": r.score.text_match,
"structural_score": r.score.structural,
"context": r.context,
"language": r.language,
})
})
.collect();
println!(
"{}",
render_tool_output(
"leindex.search",
&serde_json::json!(results_json),
&serde_json::json!({ "query": &query }),
)
);
Ok(())
}
async fn cmd_analyze_impl(
query: String,
token_budget: usize,
project: Option<PathBuf>,
) -> AnyhowResult<()> {
let project_path = get_project_path(project);
let canonical_path = project_path
.canonicalize()
.context("Failed to canonicalize project path")?;
info!("Analyzing: {}", query);
let mut leindex = LeIndex::new(&canonical_path).context("Failed to create LeIndex instance")?;
if let Err(e) = leindex.load_from_storage() {
warn!("Failed to load from storage: {}", e);
warn!("Project may not be indexed. Run 'leindex index' first.");
}
let result = leindex
.analyze(&query, token_budget)
.context("Analysis failed")?;
if std::env::var_os("LEINDEX_CLI_SHUTDOWN_DAEMON").is_some() {
leindex.shutdown_daemon();
}
let output = format_analysis_output(&query, &result);
println!("{}", output);
Ok(())
}
const CONTEXT_BUDGET: usize = 2000;
const _: () = assert!(CONTEXT_BUDGET >= 1000);
fn format_analysis_output(query: &str, result: &crate::cli::leindex::AnalysisResult) -> String {
use crate::cli::mcp::output::{BOLD, DIM, LIGHT_CYAN, RESET};
let mut out = String::new();
out.push_str(&format!(
"{}┌─ Analysis: {} ─┐{}\n",
LIGHT_CYAN, query, RESET
));
out.push_str(&format!(
" {}Found:{} {} entry point(s)\n",
BOLD,
RESET,
result.results.len()
));
out.push_str(&format!(
" {}Tokens:{} {}\n",
BOLD, RESET, result.tokens_used
));
out.push_str(&format!(
" {}Time:{} {}ms\n",
BOLD, RESET, result.processing_time_ms
));
out.push('\n');
if result.results.is_empty() {
out.push_str(&format!(" {}No results found\n", DIM));
} else {
out.push_str(&format!(
" {}Results:{} ({} entries)\n",
BOLD,
RESET,
result.results.len()
));
for r in &result.results {
out.push_str(&format!(
" {} {:>2}.{} {}{}{} {}{}{}",
BOLD, r.rank, RESET, LIGHT_CYAN, r.file_path, RESET, BOLD, r.symbol_name, RESET
));
match (&r.symbol_type, r.line_number) {
(Some(t), Some(l)) => {
out.push_str(&format!(" {}({}, line {}){}", DIM, t, l, RESET));
}
(Some(t), None) => {
out.push_str(&format!(" {}({}){}", DIM, t, RESET));
}
(None, Some(l)) => {
out.push_str(&format!(" {}(line {}){}", DIM, l, RESET));
}
(None, None) => {}
}
out.push_str(&format!(
" {}score: {:.3}{}\n",
DIM, r.score.overall, RESET
));
}
}
if let Some(context) = &result.context {
out.push('\n');
out.push_str(&format!(" {}{}{}\n", BOLD, "Context:", RESET));
let context_str: &str = context.as_str();
let truncated = crate::cli::mcp::output::truncate_chars(context_str, CONTEXT_BUDGET);
out.push_str(&format!(" {}{}{}", DIM, truncated, RESET));
}
out
}
async fn cmd_context_impl(
node_id: String,
token_budget: usize,
project: Option<PathBuf>,
) -> AnyhowResult<()> {
let args = merge_tool_args(
serde_json::json!({
"node_id": node_id,
"token_budget": token_budget
}),
&[],
project.as_ref(),
)?;
let value = execute_tool_handler("leindex_context", args, project).await?;
println!(
"{}",
render_tool_output(
"leindex.context",
&value,
&serde_json::json!({ "node_id": &node_id })
)
);
Ok(())
}
async fn cmd_diagnostics_impl(project: Option<PathBuf>) -> AnyhowResult<()> {
let project_path = get_project_path(project);
let canonical_path = project_path
.canonicalize()
.context("Failed to canonicalize project path")?;
info!("Fetching diagnostics");
let mut leindex = LeIndex::new(&canonical_path).context("Failed to create LeIndex instance")?;
if let Err(e) = leindex.load_stats_from_storage() {
warn!("Failed to load persisted stats: {}", e);
}
let diag = leindex
.get_diagnostics()
.context("Failed to get diagnostics")?;
let health = crate::cli::index_freshness::load_health(leindex.storage_path());
let indexed_ct = health
.as_ref()
.map(|snapshot| snapshot.indexed_file_count)
.unwrap_or(diag.stats.files_parsed);
let (changed, deleted) = crate::cli::git::status(leindex.project_path())
.ok()
.map(|status| {
let changed = status
.modified
.into_iter()
.chain(status.staged)
.chain(status.untracked)
.map(|path| leindex.project_path().join(path))
.collect::<Vec<_>>();
(changed, status.deleted)
})
.unwrap_or_else(|| (Vec::new(), Vec::new()));
let health_stale = health.as_ref().is_some_and(|snapshot| {
matches!(
snapshot.status,
crate::cli::leindex::ComponentStatus::Stale
| crate::cli::leindex::ComponentStatus::Partial
| crate::cli::leindex::ComponentStatus::Failed
)
});
let tree_drift = match health.as_ref().and_then(|h| h.tree_oid.as_deref()) {
Some(indexed) => match crate::cli::git::tree_oid(leindex.project_path()) {
Ok(Some(current)) => indexed != current,
Ok(None) => false,
Err(_) => true,
},
None => false,
};
let stale = health_stale || !changed.is_empty() || !deleted.is_empty() || tree_drift;
let storage_path = leindex.storage_path();
let last_indexed_secs_ago = std::fs::metadata(storage_path.join("leindex.db"))
.and_then(|m| m.modified())
.ok()
.and_then(|t| std::time::SystemTime::now().duration_since(t).ok())
.map(|d| d.as_secs());
let mut issues: Vec<serde_json::Value> = Vec::new();
if diag.stats.failed_parses > 0 {
issues.push(serde_json::json!({
"severity": "warning",
"message": format!("{} files failed to parse", diag.stats.failed_parses),
}));
}
if stale {
issues.push(serde_json::json!({
"severity": "warning",
"message": "Index may be stale. Call leindex_manage action=index with force_reindex=true for fresh results.",
}));
}
let embedding_model = diag.embedding_model.clone();
let (ort_path, ort_version, execution_provider, execution_provider_active) =
collect_ort_diagnostics();
let diag_json = serde_json::json!({
"project_path": diag.project_path,
"indexed_files": indexed_ct,
"index_size_mb": diag.memory_usage_bytes as f64 / 1024.0 / 1024.0,
"symbol_count": diag.stats.indexed_nodes,
"stale": stale,
"freshness": health,
"last_indexed_secs_ago": last_indexed_secs_ago,
"embedding_model": embedding_model,
"embedding_preset": diag.embedding_preset,
"measured_embedding_dimension": diag.measured_embedding_dimension,
"precision_enabled": diag.precision_enabled,
"precision_nodes": diag.precision_nodes,
"precision_languages": diag.precision_languages,
"ort_path": ort_path,
"ort_version": ort_version,
"execution_provider": execution_provider,
"execution_provider_active": execution_provider_active,
"issues": issues,
});
println!(
"{}",
render_tool_output("leindex.diagnostics", &diag_json, &serde_json::json!({}))
);
Ok(())
}
pub(crate) fn collect_ort_diagnostics() -> (Option<String>, Option<String>, String, Option<String>)
{
use crate::cli::leindex::setup;
use std::sync::Mutex;
static CACHED: std::sync::OnceLock<Mutex<Option<(Option<String>, Option<String>)>>> =
std::sync::OnceLock::new();
let (ort_path, ort_version) = {
let cache = CACHED.get_or_init(|| Mutex::new(None));
let mut guard = cache
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(cached) = guard.clone() {
cached
} else {
#[cfg(feature = "onnx")]
let live_path = crate::embed::ort_discovery::discover_path_only()
.map(|outcome| outcome.path.display().to_string());
#[cfg(not(feature = "onnx"))]
let live_path: Option<String> = None;
let config_path = crate::config::LeIndexConfig::load()
.ok()
.and_then(|c| c.neural.ort_dylib_path);
let ort_path = live_path.or(config_path);
let recorded_version = crate::config::LeIndexConfig::load()
.ok()
.and_then(|c| c.neural.ort_version);
let ort_version = match recorded_version {
Some(version) => Some(version),
None => setup::get_ort_version(),
};
let value = (ort_path, ort_version);
*guard = Some(value.clone());
value
}
};
let execution_provider = crate::config::LeIndexConfig::load_cached()
.neural
.execution_provider
.clone();
let execution_provider = if execution_provider.is_empty() {
"auto".to_string()
} else {
execution_provider
};
#[cfg(feature = "onnx")]
let active_provider =
crate::search::onnx::daemon_active_provider().map(|(provider, _phase)| provider);
#[cfg(not(feature = "onnx"))]
let active_provider: Option<String> = None;
(ort_path, ort_version, execution_provider, active_provider)
}
async fn cmd_serve_impl(host: String, port: u16) -> AnyhowResult<()> {
let port = if let Ok(env_port) = std::env::var("LEINDEX_PORT") {
env_port.parse::<u16>().unwrap_or(port)
} else {
port
};
let addr: SocketAddr = format!("{}:{}", host, port)
.parse()
.context("Invalid address or port")?;
info!("Starting MCP server on {}", addr);
let server = McpServer::with_address(addr).context("Failed to create MCP server")?;
let listener = crate::cli::mcp::server::bind_with_fallback(addr)
.await
.context("Bind failed")?;
let bound_addr = listener
.local_addr()
.context("Failed to read bound address")?;
if bound_addr.port() != addr.port() {
eprintln!(
"\nWARNING: preferred port {} was unavailable; bound to fallback {}\n",
addr.port(),
bound_addr.port()
);
}
println!("\nLeIndex MCP Server\n");
println!("Server starting on http://{}\n", bound_addr);
println!("Available endpoints:");
println!(" POST /mcp - JSON-RPC 2.0 endpoint");
println!(" GET /mcp/tools/list - List available tools");
println!(" GET /health - Health check");
println!("\nConfiguration:");
println!(
" Port: {} (override with LEINDEX_PORT env var; auto-falls back to next consecutive ports if taken)",
bound_addr.port()
);
println!("\nPress Ctrl+C to stop the server\n");
server.serve(listener).await.context("Server error")?;
Ok(())
}
async fn cmd_dashboard_impl(port: u16, prod: bool) -> AnyhowResult<()> {
use std::process::Command;
let current_dir = std::env::current_dir().context("Failed to get current directory")?;
let dashboard_path = {
let mut candidates = Vec::new();
candidates.push(current_dir.join("dashboard"));
let mut parent = current_dir.as_path();
for _ in 0..5 {
if let Some(next) = parent.parent() {
candidates.push(next.join("dashboard"));
parent = next;
} else {
break;
}
}
if let Ok(explicit) = std::env::var("LEINDEX_DASHBOARD_DIR") {
candidates.push(PathBuf::from(explicit));
}
if let Ok(home) = std::env::var("HOME") {
candidates.push(PathBuf::from(home).join(".leindex").join("dashboard"));
}
candidates
.into_iter()
.find(|path| path.exists() && path.is_dir())
.ok_or_else(|| {
anyhow::anyhow!(
"Dashboard directory not found. Checked current repo paths, LEINDEX_DASHBOARD_DIR, and ~/.leindex/dashboard."
)
})?
};
let bun_exists = Command::new("bun")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !bun_exists {
anyhow::bail!(
"Bun is required to run the dashboard. Please install it first:\n curl -fsSL https://bun.sh/install | bash"
);
}
println!("\nLeIndex Dashboard\n");
println!("Starting dashboard server...\n");
if prod {
println!("Building dashboard for production...");
let build_status = Command::new("bun")
.current_dir(&dashboard_path)
.arg("run")
.arg("build")
.status()
.context("Failed to build dashboard")?;
if !build_status.success() {
anyhow::bail!("Dashboard build failed");
}
println!("\nDashboard built successfully!");
println!("Built files: {}/dist", dashboard_path.display());
println!("\nTo serve the production build, use:");
println!(" cd {} && bun run start", dashboard_path.display());
} else {
println!("Dashboard will be available at: http://localhost:{}", port);
println!("Press Ctrl+C to stop the server\n");
let status = Command::new("bun")
.current_dir(&dashboard_path)
.arg("run")
.arg("dev")
.status()
.context("Failed to start dashboard")?;
if !status.success() {
anyhow::bail!("Dashboard server exited with error");
}
}
Ok(())
}
async fn cmd_setup_impl(
neural: bool,
no_neural: bool,
cpu: bool,
gpu: Option<String>,
check: bool,
warmup: bool,
remote_gemma: bool,
options: crate::cli::leindex::setup::SetupOptions,
) -> AnyhowResult<()> {
use crate::cli::leindex::setup;
if check {
return run_setup_check_mode(
neural,
no_neural,
cpu,
gpu.as_deref(),
warmup,
remote_gemma,
&options,
);
}
if setup::configure_system_one_if_standalone(
&options,
neural || no_neural || cpu || gpu.is_some() || remote_gemma,
warmup,
)? {
return Ok(());
}
let choices = build_setup_choices(neural, no_neural, cpu, gpu, remote_gemma, options)?;
let result = setup::execute_setup(&choices).map_err(|e| anyhow::anyhow!("{}", e))?;
finish_setup(&choices, &result, warmup)
}
fn build_setup_choices(
neural: bool,
no_neural: bool,
cpu: bool,
gpu: Option<String>,
remote_gemma: bool,
options: crate::cli::leindex::setup::SetupOptions,
) -> AnyhowResult<crate::cli::leindex::setup::SetupChoices> {
use crate::cli::leindex::setup;
let gpu_vendor = gpu
.as_deref()
.map(setup::parse_gpu_vendor)
.transpose()
.map_err(|e| anyhow::anyhow!("{}", e))?;
let has_flags =
neural || no_neural || cpu || gpu.is_some() || remote_gemma || options.has_flags();
if has_flags {
check_neutral_conflicts(neural, no_neural, cpu, gpu.as_deref())?;
}
let embedding_requested = neural || remote_gemma || options.embedding.is_some();
let mut choices =
resolve_setup_choices(embedding_requested, no_neural, cpu, gpu_vendor, has_flags)?;
choices.remote_gemma = remote_gemma;
if has_flags {
choices.options = options;
}
if onnx_provider_conflict(
cpu,
gpu.is_some(),
remote_gemma,
choices.options.embedding.as_deref(),
) {
anyhow::bail!("--cpu/--gpu select ONNX providers; use --gpu-layers for managed llama.cpp");
}
Ok(choices)
}
#[cfg_attr(not(feature = "onnx"), allow(unused_variables))]
fn finish_setup(
choices: &crate::cli::leindex::setup::SetupChoices,
result: &crate::cli::leindex::setup::SetupResult,
warmup: bool,
) -> AnyhowResult<()> {
use crate::cli::leindex::setup;
setup::print_summary(result);
#[cfg(feature = "onnx")]
let do_warmup = warmup || should_auto_warmup(choices, result);
#[cfg(not(feature = "onnx"))]
let do_warmup = warmup;
handle_smoke_and_warmup(result, do_warmup)
}
fn onnx_provider_conflict(
cpu: bool,
gpu: bool,
remote_gemma: bool,
embedding: Option<&str>,
) -> bool {
(cpu || gpu) && (remote_gemma || embedding.is_some_and(|r| r != "qwen3"))
}
fn run_setup_check_mode(
neural: bool,
no_neural: bool,
cpu: bool,
gpu: Option<&str>,
warmup: bool,
remote_gemma: bool,
options: &crate::cli::leindex::setup::SetupOptions,
) -> AnyhowResult<()> {
use crate::cli::leindex::setup;
if neural || no_neural || cpu || gpu.is_some() || warmup || remote_gemma || options.has_flags()
{
anyhow::bail!("--check cannot be combined with setup configuration flags");
}
check_neutral_conflicts(neural, no_neural, cpu, gpu)?;
let result = setup::run_check().map_err(|e| anyhow::anyhow!("{}", e))?;
if !result.fully_configured {
anyhow::bail!("setup check incomplete");
}
Ok(())
}
fn resolve_setup_choices(
neural: bool,
no_neural: bool,
cpu: bool,
gpu_vendor: Option<crate::cli::leindex::setup::GpuVendor>,
has_flags: bool,
) -> AnyhowResult<crate::cli::leindex::setup::SetupChoices> {
use crate::cli::leindex::setup;
if has_flags {
setup::resolve_from_flags(neural, no_neural, cpu, gpu_vendor)
.map_err(|e| anyhow::anyhow!("{}", e))
} else if setup::is_interactive() {
setup::run_interactive_flow().map_err(|e| anyhow::anyhow!("{}", e))
} else {
eprintln!("No setup options specified and stdin/stderr is not a terminal.");
eprintln!("For non-interactive setup, use flags:");
eprintln!(" leindex setup --neural --cpu # CPU neural search");
eprintln!(" leindex setup --neural --gpu amd # AMD GPU (MIGraphX)");
eprintln!(" leindex setup --neural --gpu nvidia # NVIDIA GPU (CUDA)");
eprintln!(" leindex setup --no-neural # TF-IDF only");
eprintln!(" leindex setup --check # Show current status");
anyhow::bail!("No setup options specified in non-interactive mode")
}
}
fn handle_smoke_and_warmup(
result: &crate::cli::leindex::setup::SetupResult,
do_warmup: bool,
) -> AnyhowResult<()> {
if let Some(smoke) = &result.smoke_test {
if !smoke.passed && !smoke.skipped {
anyhow::bail!("setup smoke test failed");
}
}
#[cfg(feature = "onnx")]
if do_warmup {
if let Err(e) = crate::cli::leindex::setup::run_warmup() {
eprintln!(" -> MIGraphX warmup warning: {}", e);
}
}
#[cfg(not(feature = "onnx"))]
if do_warmup {
eprintln!(" -> MIGraphX warmup skipped: ONNX feature not enabled");
}
Ok(())
}
#[cfg(feature = "onnx")]
fn should_auto_warmup(
_choices: &crate::cli::leindex::setup::SetupChoices,
result: &crate::cli::leindex::setup::SetupResult,
) -> bool {
let active_is_migraphx = result
.smoke_test
.as_ref()
.and_then(|s| s.execution_provider.as_deref())
== Some("migraphx");
if !active_is_migraphx {
return false;
}
if !result.ort_installed || !result.model_present {
return false;
}
let cache_path =
crate::search::onnx::client::migraphx_cache_path("qwen3-embed-0.6b-dynamic-uint8");
!cache_path.exists()
}
fn check_neutral_conflicts(
neural: bool,
no_neural: bool,
cpu: bool,
gpu: Option<&str>,
) -> AnyhowResult<()> {
if neural && no_neural {
anyhow::bail!("Cannot use --neural and --no-neural together. Choose one.");
}
if cpu && gpu.is_some() {
anyhow::bail!("Cannot use --cpu and --gpu together. Choose one execution provider.");
}
Ok(())
}
pub async fn main() -> AnyhowResult<()> {
match Cli::try_parse() {
Ok(cli) => cli.run().await,
Err(err) => {
if matches!(
err.kind(),
ErrorKind::DisplayHelp | ErrorKind::DisplayVersion
) {
maybe_complete_post_install_actions(&Commands::Diagnostics);
}
err.exit()
}
}
}
#[cfg(test)]
#[path = "cli_test.rs"]
mod tests;